From 77b2d929a1b2c340ce4ab909b322aa44ebe72103 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:27:43 +0000 Subject: [PATCH 1/6] feat(mcp): Return structured get_issue_details for every event type In experimental mode get_issue_details now returns schema-valid structuredContent on every success path, including event types the markdown path does not support, and advertises its outputSchema to experimental sessions. Stable sessions keep the markdown output. - event maps the entries into fields instead of carrying a markdown body: exception chain (raised first), thread summaries, crashed thread, request, CSP, N+1 evidence and spans, metric alert, evidence, user, tags, extra and contexts. - Frames use a shared shape (tools/support/structured-stacktrace.ts) that get_event_stacktrace will reuse. - responseNotes become suggestedActions. - New experimentalOutputSchema tool field, advertised only in experimental mode; removed once the tool graduates. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- docs/contributing/tool-responses.md | 25 +- packages/mcp-core/src/api-client/schema.ts | 31 +- packages/mcp-core/src/internal/formatting.ts | 305 ++++-- packages/mcp-core/src/server.metadata.test.ts | 23 +- packages/mcp-core/src/server.test.ts | 26 + packages/mcp-core/src/server.ts | 4 +- .../get-issue-details.structured.test.ts | 872 ++++++++++++++++++ .../tools/catalog/get-issue-details.test.ts | 150 ++- .../src/tools/catalog/get-issue-details.ts | 355 ++++--- .../src/tools/support/issue-event-payload.ts | 489 ++++++++++ .../tools/support/structured-stacktrace.ts | 171 ++++ packages/mcp-core/src/tools/types.ts | 8 + 12 files changed, 2158 insertions(+), 301 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts create mode 100644 packages/mcp-core/src/tools/support/issue-event-payload.ts create mode 100644 packages/mcp-core/src/tools/support/structured-stacktrace.ts diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 322afc38c..9284312ac 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -147,6 +147,19 @@ The event API's legacy `context` field (extra data) can be `null`. Issue details must still load; omit the `Extra Data` section for null or empty values while preserving the event's error, stacktrace, and structured `contexts` data. +In experimental mode `get_issue_details` returns `structuredContent` for every +event type, including types the markdown path does not support, and declares +`outputSchema` to experimental sessions only (`experimentalOutputSchema`). +`event` maps the entries into fields instead of rendering markdown: the +exception chain (the raised exception first), thread summaries, the crashed +thread when there is no exception, request method and URL, CSP report, N+1 +evidence and spans, metric alert, evidence, user (including email and IP), tags, +extra data, and contexts. Frames use the shared shape in +`tools/support/structured-stacktrace.ts`: oldest call first, `contextLine` for +every frame, and `preContext`, `postContext` (3 lines each) and `vars` for +in-app frames only. Request headers, cookies and body, frame addresses, symbols +and source links, tag search queries, and external issue link ids stay out. + ### Profile Details `get_profile_details` returns one profile summary under `profile`, @@ -209,10 +222,14 @@ Policy (encoded in `packages/mcp-core/src/internal/tool-helpers/results.ts`): `content` as compact JSON (no indentation) of that same payload. Most clients that read the text send it to a model, and indentation adds tokens without adding data. -- Markdown-only tools (for example issue and trace details today) should return +- Markdown-only tools (for example trace details today) should return markdown and omit `structuredContent` until they have a real structured payload. Do not attach sparse structured side-channels such as `suggestedActions` alone on top of markdown. +- A tool whose structured payload is still behind experimental mode declares + `experimentalOutputSchema` instead. The server advertises it to experimental + sessions only, and tsc cannot check the handler against it, so the tool's + tests must parse every structured payload against the schema. - If a tool declares `outputSchema`, every successful `structuredContent` result must conform to that schema. `defineTool` enforces this at compile time: the handler must return `structuredResult(...)` typed as the schema's `z.output`, @@ -280,8 +297,10 @@ reason: - `OUTPUT_SCHEMA_PENDING` for tools whose `outputSchema` has not landed, with the PR, for example `"pending getsentry/toolkit#2004"`. -An entry fails once its tool has an `outputSchema`, so the PR that adds one -also deletes the entry. To add a `_meta` key, add it to `ALLOWED_TOOL_META` +An entry fails once its tool has an `outputSchema` in every session that lists +it, so the PR that adds one also deletes the entry. A tool whose +`experimentalOutputSchema` reaches experimental sessions first stays +`OUTPUT_SCHEMA_PENDING` until it graduates. To add a `_meta` key, add it to `ALLOWED_TOOL_META` with a comment that says which client reads it. ## Suggested Actions diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e2605bf25..5d31d0b2b 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -907,6 +907,18 @@ export const FrameInterface = z }) .partial(); +const StacktraceSchema = z + .object({ + frames: z.array(FrameInterface), + framesOmitted: z.array(z.unknown()).nullable().optional(), + registers: z.record(z.string(), z.unknown()).nullable().optional(), + hasSystemFrames: z.boolean().nullable().optional(), + }) + .partial() + .extend({ + frames: z.array(FrameInterface), + }); + // XXX: Sentry's schema generally speaking is "assume all user input is missing" // so we need to handle effectively every field being optional or nullable. export const ExceptionInterface = z @@ -920,11 +932,8 @@ export const ExceptionInterface = z .nullable(), type: z.string().nullable(), value: z.string().nullable(), - stacktrace: z - .object({ - frames: z.array(FrameInterface), - }) - .nullable(), + module: z.string().nullable(), + stacktrace: StacktraceSchema.nullable(), }) .partial(); @@ -955,18 +964,6 @@ export const MessageEntrySchema = z }) .partial(); -const StacktraceSchema = z - .object({ - frames: z.array(FrameInterface), - framesOmitted: z.array(z.unknown()).nullable().optional(), - registers: z.record(z.string(), z.unknown()).nullable().optional(), - hasSystemFrames: z.boolean().nullable().optional(), - }) - .partial() - .extend({ - frames: z.array(FrameInterface), - }); - export const ThreadEntrySchema = z .object({ id: z.union([z.number(), z.string()]).nullable(), diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 2da18eb80..388cfe022 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -710,7 +710,7 @@ export function formatAvailableThreadList( ].join("\n"); } -function parseThreadsEntryData( +export function parseThreadsEntryData( data: unknown, ): z.infer | undefined { const result = ThreadsEntrySchema.safeParse(data); @@ -930,7 +930,7 @@ const MAX_SPANS_IN_TREE = 10; /** * Safely parse a number from a string, returning a default if invalid */ -function safeParseInt(value: unknown, defaultValue: number): number { +export function safeParseInt(value: unknown, defaultValue: number): number { if (typeof value === "number") return value; if (typeof value === "string") { const parsed = Number.parseInt(value, 10); @@ -943,11 +943,11 @@ function safeParseInt(value: unknown, defaultValue: number): number { * Simplified span structure for rendering span trees in performance issues. * This is a subset of the full span data focused on visualization needs. */ -interface PerformanceSpan { - span_id: string; - op: string; // Operation type (e.g., "db.query", "http.client") - description: string; // Human-readable description of what the span did - duration: number; // Duration in milliseconds +export interface PerformanceSpan { + span_id: string | null; + op: string | null; // Operation type (e.g., "db.query", "http.client") + description: string | null; // Human-readable description of what the span did + duration: number | null; // Duration in milliseconds, null when not recorded is_n1_query: boolean; // Whether this span is part of the N+1 pattern children: PerformanceSpan[]; level: number; // Nesting level for tree rendering @@ -963,7 +963,7 @@ interface RawSpan { duration?: number; } -interface N1EvidenceData { +export interface N1EvidenceData { parentSpan?: string; parentSpanIds?: string[]; repeatingSpansCompact?: string[]; @@ -997,7 +997,7 @@ function getSpanIdentifier(span: RawSpan): string | undefined { return undefined; } -function getSpanDurationMs(span: RawSpan): number { +function getSpanDurationMs(span: RawSpan): number | null { if ( typeof span.timestamp === "number" && typeof span.start_timestamp === "number" @@ -1010,10 +1010,10 @@ function getSpanDurationMs(span: RawSpan): number { // Trace APIs expose `duration` in milliseconds. Preserve fractional values. if (typeof span.duration === "number" && Number.isFinite(span.duration)) { - return span.duration >= 0 ? span.duration : 0; + return span.duration >= 0 ? span.duration : null; } - return 0; + return null; } function normalizeIdArray(values: unknown): string[] { @@ -1034,7 +1034,7 @@ function isValidSpanArray(value: unknown): value is RawSpan[] { * Get the repeating span descriptions from evidence data. * Prefers repeatingSpansCompact (more concise) over repeatingSpans (verbose). */ -function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] { +export function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] { // Try compact version first (preferred for display) if ( Array.isArray(evidenceData.repeatingSpansCompact) && @@ -1069,12 +1069,12 @@ function isTraceSpan(node: unknown): node is TraceSpan { ); } -function buildTraceSpanTree( +function selectTraceSpanRoots( trace: Trace, parentSpanIds: string[], offenderSpanIds: string[], maxSpans: number, -): string[] { +): PerformanceSpan[] { const offenderSet = new Set(offenderSpanIds); const spanMap = new Map(); @@ -1143,11 +1143,59 @@ function buildTraceSpanTree( } } - if (roots.length === 0) { - return []; - } + return roots; +} - return renderPerformanceSpanTree(roots); +/** + * The spans an N+1 issue points at, as a tree of at most MAX_SPANS_IN_TREE + * spans: from the full trace when it was fetched, otherwise from the event's + * own spans, otherwise the offending spans alone. + */ +export function selectN1EvidenceSpans( + evidenceData: N1EvidenceData, + spansData: unknown, + performanceTrace?: Trace, +): PerformanceSpan[] { + const parentSpanIds = normalizeIdArray(evidenceData.parentSpanIds); + const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds); + const traceRoots = performanceTrace + ? selectTraceSpanRoots( + performanceTrace, + parentSpanIds, + offenderSpanIds, + MAX_SPANS_IN_TREE, + ) + : []; + if (traceRoots.length > 0 || !isValidSpanArray(spansData)) { + return traceRoots; + } + const spanTree = selectN1QuerySpans( + spansData, + evidenceData, + MAX_SPANS_IN_TREE, + ); + if (spanTree.length > 0) { + return spanTree; + } + const spansById = new Map(); + for (const span of spansData) { + const identifier = getSpanIdentifier(span); + if (identifier) { + spansById.set(identifier, span); + } + } + return offenderSpanIds.slice(0, MAX_SPANS_IN_TREE).map((offenderId) => { + const span = spansById.get(offenderId); + return { + span_id: offenderId, + op: span?.op || null, + description: span?.description || null, + duration: span ? getSpanDurationMs(span) : null, + is_n1_query: true, + children: [], + level: 0, + }; + }); } function convertTraceSpanToPerformanceSpan( @@ -1168,7 +1216,7 @@ function convertTraceSpanToPerformanceSpan( const performanceSpan: PerformanceSpan = { span_id: spanId, - op: span.op || "unknown", + op: span.op || null, description: formatTraceSpanDescription(span), duration: getTraceSpanDurationMs(span), is_n1_query: offenderSet.has(spanId), @@ -1200,7 +1248,7 @@ function convertTraceSpanToPerformanceSpan( return performanceSpan; } -function formatTraceSpanDescription(span: TraceSpan): string { +function formatTraceSpanDescription(span: TraceSpan): string | null { if (span.name && span.name.trim().length > 0) { return span.name.trim(); } @@ -1210,10 +1258,10 @@ function formatTraceSpanDescription(span: TraceSpan): string { if (span.op && span.op.trim().length > 0) { return span.op.trim(); } - return "unnamed"; + return null; } -function getTraceSpanDurationMs(span: TraceSpan): number { +function getTraceSpanDurationMs(span: TraceSpan): number | null { if (typeof span.duration === "number" && span.duration >= 0) { return span.duration; } @@ -1227,7 +1275,7 @@ function getTraceSpanDurationMs(span: TraceSpan): number { return deltaSeconds * 1000; } } - return 0; + return null; } function buildOffenderSummaries( @@ -1252,7 +1300,8 @@ function buildOffenderSummaries( if (span) { const description = span.description || span.op || `Span ${offenderId}`; const duration = getSpanDurationMs(span); - const durationLabel = duration > 0 ? ` (${Math.round(duration)}ms)` : ""; + const durationLabel = + duration != null && duration > 0 ? ` (${Math.round(duration)}ms)` : ""; summaries.push(`${description}${durationLabel} [${offenderId}] [N+1]`); } else { summaries.push(`Span ${offenderId} [N+1]`); @@ -1278,7 +1327,9 @@ function renderPerformanceSpanTree(spans: PerformanceSpan[]): string[] { const displayName = span.description?.trim() || span.op || "unnamed"; const spanId = span.span_id ?? "unknown"; const durationDisplay = - span.duration > 0 ? `${Math.round(span.duration)}ms` : "unknown"; + span.duration != null && span.duration > 0 + ? `${Math.round(span.duration)}ms` + : "unknown"; // Full span ID goes last so human-readable parts read first. const metadataParts: string[] = []; @@ -1330,10 +1381,9 @@ function selectN1QuerySpans( if (parent) { parentSpan = { - span_id: getSpanIdentifier(parent) ?? "unknown", - op: parent.op || "unknown", - description: - parent.description || evidence.parentSpan || "Parent Operation", + span_id: getSpanIdentifier(parent) ?? null, + op: parent.op || null, + description: parent.description || evidence.parentSpan || null, duration: getSpanDurationMs(parent), is_n1_query: false, children: [], @@ -1355,9 +1405,9 @@ function selectN1QuerySpans( for (const span of offenderSpans) { const perfSpan: PerformanceSpan = { - span_id: getSpanIdentifier(span) ?? "unknown", - op: span.op || "db.query", - description: span.description || "Database Query", + span_id: getSpanIdentifier(span) ?? null, + op: span.op || null, + description: span.description || null, duration: getSpanDurationMs(span), is_n1_query: true, children: [], @@ -1398,7 +1448,7 @@ function selectN1QuerySpans( * - File I/O issues * - Consecutive queries */ -const KNOWN_PERFORMANCE_ISSUE_TYPES = { +export const KNOWN_PERFORMANCE_ISSUE_TYPES = { N_PLUS_ONE_DB_QUERIES: "performance_n_plus_one_db_queries", N_PLUS_ONE_API_CALLS: "performance_n_plus_one_api_calls", SLOW_DB_QUERY: "performance_slow_db_query", @@ -1421,7 +1471,7 @@ const KNOWN_PERFORMANCE_ISSUE_TYPES = { * Range: 1xxx = transaction-based performance issues * 2xxx = profile-based performance issues */ -const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record = { +export const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record = { 1001: KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY, 1004: KNOWN_PERFORMANCE_ISSUE_TYPES.RENDER_BLOCKING_ASSET, 1006: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, @@ -1501,11 +1551,13 @@ function formatN1QueryEvidence( const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds); const traceLines = performanceTrace - ? buildTraceSpanTree( - performanceTrace, - parentSpanIds, - offenderSpanIds, - MAX_SPANS_IN_TREE, + ? renderPerformanceSpanTree( + selectTraceSpanRoots( + performanceTrace, + parentSpanIds, + offenderSpanIds, + MAX_SPANS_IN_TREE, + ), ) : []; @@ -1692,7 +1744,8 @@ type MetricIssueEvidenceData = Extract< >["evidenceData"]; type MetricIssueDataSource = MetricIssueEvidenceData["dataSources"][number]; type MetricIssueSnubaQuery = MetricIssueDataSource["queryObj"]["snubaQuery"]; -type MetricIssueCondition = MetricIssueEvidenceData["conditions"][number]; +export type MetricIssueCondition = + MetricIssueEvidenceData["conditions"][number]; /** * Keys come from the generated `@sentry/api` metric occurrence type, so a @@ -1714,7 +1767,7 @@ type MetricAlertQuery = { eventTypes: string[]; }; -type MetricAlertContext = { +export type MetricAlertContext = { evidenceData: Unverified; query?: MetricAlertQuery; monitorId?: string; @@ -1726,7 +1779,9 @@ type MetricAlertContext = { * camelCases occurrence payloads recursively, so evidence keys arrive as * `dataSources[].queryObj.snubaQuery`, `detectorId`, and `alertId`. */ -function getMetricAlertContext(event: Event): MetricAlertContext | undefined { +export function getMetricAlertContext( + event: Event, +): MetricAlertContext | undefined { if (event.type !== "generic") { return undefined; } @@ -1786,7 +1841,7 @@ function getMetricAlertQuery( type MetricAlertDatasetKind = "errors" | "logs" | "spans" | "metrics"; /** Mirrors the Sentry UI's backend dataset -> detector dataset mapping. */ -function getMetricAlertDatasetKind( +export function getMetricAlertDatasetKind( query: MetricAlertQuery, ): MetricAlertDatasetKind | undefined { if (query.dataset === "events") { @@ -1815,7 +1870,7 @@ const METRIC_ALERT_SEARCH_TOOLS: Record< }; /** Returns the window the monitor aggregated over for this occurrence. */ -function getMetricAlertEvaluatedWindow( +export function getMetricAlertEvaluatedWindow( event: Event, query: MetricAlertQuery | undefined, ): { start: Date; end: Date } | undefined { @@ -1844,7 +1899,7 @@ function formatPeriodSince(start: Date, now: number): string { return hours <= 48 ? `${hours}h` : `${Math.ceil(hours / 24)}d`; } -function formatMetricAlertCondition( +export function formatMetricAlertCondition( condition: Unverified, ): string | undefined { const comparison = condition.comparison; @@ -1874,7 +1929,7 @@ function formatMetricAlertCondition( return formattedComparison; } -function formatMetricAlertValue(value: unknown): string | undefined { +export function formatMetricAlertValue(value: unknown): string | undefined { const formattedValue = formatPrimitive(value); if (formattedValue) { return formattedValue; @@ -1950,51 +2005,44 @@ function formatMetricAlertDetails( return `${parts.join("\n")}\n`; } +type MetricAlertFollowUpCalls = { + monitor?: { toolName: string; arguments: Record }; + search?: { + toolName: string; + arguments: Record; + label: string; + window: { start: Date; end: Date }; + }; +}; + /** - * Follow-up notes for metric issues: the monitor configuration and a search + * Follow-up calls for metric issues: the monitor configuration and a search * call that reproduces the monitor query over the evaluated window. */ -function buildMetricAlertResponseNotes({ +export function getMetricAlertFollowUpCalls({ organizationSlug, issue, event, context, - experimentalMode, - availableToolNames, - directToolNames, }: { organizationSlug: string; issue: Issue; event: Event; context: MetricAlertContext; - experimentalMode: boolean; - availableToolNames?: ReadonlySet; - directToolNames?: ReadonlySet; -}): string[] { - const notes: string[] = []; - +}): MetricAlertFollowUpCalls { + const calls: MetricAlertFollowUpCalls = {}; if (context.monitorId) { - const monitorInstruction = formatToolCallInstruction({ + calls.monitor = { toolName: "get_metric_monitor_details", arguments: { organizationSlug, monitorId: context.monitorId }, - showArguments: true, - experimentalMode, - availableToolNames, - directToolNames, - fallbackInstruction: "", - }); - if (monitorInstruction) { - notes.push( - `Metric Monitor configuration and connected Alerts: ${monitorInstruction}`, - ); - } + }; } const query = context.query; const datasetKind = query ? getMetricAlertDatasetKind(query) : undefined; const evaluatedWindow = getMetricAlertEvaluatedWindow(event, query); if (!query || !datasetKind || !evaluatedWindow) { - return notes; + return calls; } const { toolName, label } = METRIC_ALERT_SEARCH_TOOLS[datasetKind]; @@ -2012,7 +2060,7 @@ function buildMetricAlertResponseNotes({ ] .filter(Boolean) .join(" "); - const searchInstruction = formatToolCallInstruction({ + calls.search = { toolName, arguments: { organizationSlug, @@ -2020,16 +2068,68 @@ function buildMetricAlertResponseNotes({ ...(searchQuery ? { query: searchQuery } : {}), period: formatPeriodSince(evaluatedWindow.start, Date.now()), }, - showArguments: true, - experimentalMode, - availableToolNames, - directToolNames, - fallbackInstruction: "", + label, + window: evaluatedWindow, + }; + return calls; +} + +function buildMetricAlertResponseNotes({ + organizationSlug, + issue, + event, + context, + experimentalMode, + availableToolNames, + directToolNames, +}: { + organizationSlug: string; + issue: Issue; + event: Event; + context: MetricAlertContext; + experimentalMode: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; +}): string[] { + const notes: string[] = []; + const calls = getMetricAlertFollowUpCalls({ + organizationSlug, + issue, + event, + context, }); - if (searchInstruction) { - notes.push( - `${label} matched by the monitor query: ${searchInstruction}, then focus on ${evaluatedWindow.start.toISOString()} to ${evaluatedWindow.end.toISOString()}`, - ); + + if (calls.monitor) { + const monitorInstruction = formatToolCallInstruction({ + ...calls.monitor, + showArguments: true, + experimentalMode, + availableToolNames, + directToolNames, + fallbackInstruction: "", + }); + if (monitorInstruction) { + notes.push( + `Metric Monitor configuration and connected Alerts: ${monitorInstruction}`, + ); + } + } + + if (calls.search) { + const searchInstruction = formatToolCallInstruction({ + toolName: calls.search.toolName, + arguments: calls.search.arguments, + showArguments: true, + experimentalMode, + availableToolNames, + directToolNames, + fallbackInstruction: "", + }); + if (searchInstruction) { + notes.push( + `${calls.search.label} matched by the monitor query: ${searchInstruction}, then focus on ${calls.search.window.start.toISOString()} to ${calls.search.window.end.toISOString()}`, + ); + } } return notes; @@ -2255,6 +2355,30 @@ export function getSuspectCommit(committers: CommitterList | undefined) { }; } +/** Reports an event type this server cannot render yet, so support can be added. */ +export function reportUnsupportedEventType({ + organizationSlug, + issue, + event, +}: { + organizationSlug: string; + issue: Issue; + event: Event; +}): string | undefined { + return logIssue(`Unsupported event type encountered: ${String(event.type)}`, { + contexts: { + event: { + event_id: event.id, + event_type: event.type, + issue_id: issue.id, + issue_short_id: issue.shortId, + organization_slug: organizationSlug, + project_slug: issue.project.slug, + }, + }, + }); +} + /** * Formats a Sentry issue with its latest event into comprehensive markdown output. * Includes issue metadata, event details, and usage instructions. @@ -2389,22 +2513,11 @@ export function formatIssueOutput({ // But in practice we may have other types returned as UnknownEvent const eventType = event.type; if (!isSupportedEventType(event)) { - // Log to Sentry for tracking new/unknown event types - const sentryEventId = logIssue( - `Unsupported event type encountered: ${String(eventType)}`, - { - contexts: { - event: { - event_id: event.id, - event_type: eventType, - issue_id: issue.id, - issue_short_id: issue.shortId, - organization_slug: organizationSlug, - project_slug: issue.project.slug, - }, - }, - }, - ); + const sentryEventId = reportUnsupportedEventType({ + organizationSlug, + issue, + event, + }); output += `⚠️ **Warning**: Unsupported event type "${String(eventType)}"\n\n`; output += "This event type is not yet fully supported by the MCP server. "; diff --git a/packages/mcp-core/src/server.metadata.test.ts b/packages/mcp-core/src/server.metadata.test.ts index a5edaf656..fdf85250d 100644 --- a/packages/mcp-core/src/server.metadata.test.ts +++ b/packages/mcp-core/src/server.metadata.test.ts @@ -38,8 +38,6 @@ const ALLOWED_TOOL_META: Record = { /** Tools that return no `structuredContent` by design. */ const OUTPUT_SCHEMA_EXEMPT: Record = { - get_issue_details: - "markdown-only by policy (docs/contributing/tool-responses.md)", get_trace_details: "markdown-only by policy (docs/contributing/tool-responses.md)", get_event_stacktrace: @@ -57,6 +55,8 @@ const OUTPUT_SCHEMA_EXEMPT: Record = { * its PR merges; a test below fails until you do. */ const OUTPUT_SCHEMA_PENDING: Record = { + get_issue_details: + "pending: outputSchema is experimental-only until it graduates", search_attributes: "pending: experimental-only, no outputSchema PR yet", }; @@ -233,22 +233,23 @@ describe.each(Object.keys(MODES))("%s mode", (mode) => { }); describe("outputSchema skip lists", () => { - const listed = () => - new Map( - [...surfaces.values()].flatMap(({ tools }) => - tools.map((tool) => [tool.name, tool] as const), - ), + // a tool can declare its outputSchema to experimental sessions first, so an entry is + // stale only once every surface that lists the tool declares one + const listings = (name: string) => + [...surfaces.values()].flatMap(({ tools }) => + tools.filter((tool) => tool.name === name), ); it("name listed tools without an outputSchema", () => { - const tools = listed(); const problems = [ ...Object.keys(OUTPUT_SCHEMA_EXEMPT).map((name) => ["EXEMPT", name]), ...Object.keys(OUTPUT_SCHEMA_PENDING).map((name) => ["PENDING", name]), ].flatMap(([list, name]) => { - const tool = tools.get(name); - if (!tool) return [`${name}: in OUTPUT_SCHEMA_${list} but not listed`]; - if (tool.outputSchema) { + const tools = listings(name); + if (tools.length === 0) { + return [`${name}: in OUTPUT_SCHEMA_${list} but not listed`]; + } + if (tools.every((tool) => tool.outputSchema)) { return [ `${name}: has an outputSchema now; delete its OUTPUT_SCHEMA_${list} entry`, ]; diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 5e8019607..5b2d0aabe 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -766,6 +766,32 @@ describe("buildServer", () => { }); }); + describe("experimental output schema", () => { + const createServer = (experimentalMode: boolean) => + buildServer({ + context: baseContext, + experimentalMode, + tools: { + graduating_tool: createMockTool("graduating_tool", { + experimentalOutputSchema: z.object({ status: z.string() }), + }), + }, + }); + + it("advertises the schema to experimental sessions", async () => { + const [tool] = await listRegisteredTools(createServer(true)); + expect(tool?.outputSchema).toMatchObject({ + type: "object", + properties: { status: { type: "string" } }, + }); + }); + + it("keeps it from stable sessions, which still get markdown", async () => { + const [tool] = await listRegisteredTools(createServer(false)); + expect(tool?.outputSchema).toBeUndefined(); + }); + }); + describe("experimental tool filtering", () => { // Note: Experimental filtering is applied consistently to both default and custom tools. // Tools marked with `experimental: true` are only shown when `experimentalMode: true`. diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 4a6c48810..453ead47e 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -402,7 +402,9 @@ function configureServer({ title: tool.title, description: resolvedDescription, inputSchema: filteredInputSchema, - outputSchema: tool.outputSchema, + outputSchema: + tool.outputSchema ?? + (experimentalMode ? tool.experimentalOutputSchema : undefined), annotations: tool.annotations, }; const handleToolCall = async (params: unknown): Promise => { diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts new file mode 100644 index 000000000..89bf5a737 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts @@ -0,0 +1,872 @@ +import { issueFixture, mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { getStructuredContent } from "../../test-utils/structured-content"; +import getIssueDetails, { + getIssueDetailsOutputSchema, +} from "./get-issue-details.js"; + +// A Django error as the event api returns it: the chained exception, frames with +// ±5 context lines and local variables, a request with headers and cookies, the +// user, tags with their search queries, contexts and the extra data. +const ORG = "sentry-mcp-evals"; +const GROUP_ID = "6823019447"; +const EVENT_ID = "0f6c5d7e1b2a4c3d9e8f7a6b5c4d3e2f"; +const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; + +function frame(overrides: Record) { + return { + filename: null, + absPath: null, + module: null, + package: null, + platform: null, + instructionAddr: null, + symbolAddr: null, + function: null, + rawFunction: null, + symbol: null, + context: [], + lineNo: null, + colNo: null, + inApp: false, + trust: null, + errors: null, + lock: null, + sourceLink: null, + vars: null, + ...overrides, + }; +} + +function context(start: number, lines: string[]): Array<[number, string]> { + return lines.map((line, index) => [start + index, line]); +} + +const djangoFrame = frame({ + filename: "django/core/handlers/base.py", + absPath: + "/usr/local/lib/python3.12/site-packages/django/core/handlers/base.py", + module: "django.core.handlers.base", + function: "_get_response", + lineNo: 197, + context: context(191, [ + " if response is None:", + " wrapped_callback = self.make_view_atomic(callback)", + " # If it is an asynchronous view, run it in a subthread.", + " if iscoroutinefunction(wrapped_callback):", + " wrapped_callback = async_to_sync(wrapped_callback)", + " try:", + " response = wrapped_callback(request, *callback_args, **callback_kwargs)", + " except Exception as e:", + " response = self.process_exception_by_middleware(e, request)", + " if response is None:", + " raise", + ]), + vars: { + callback: "", + request: "", + }, +}); + +const viewFrame = frame({ + filename: "shop/views.py", + absPath: "/app/shop/views.py", + module: "shop.views", + function: "checkout", + lineNo: 42, + inApp: true, + context: context(37, [ + "@require_POST", + "def checkout(request):", + " cart = Cart.objects.get(user=request.user)", + " payload = json.loads(request.body)", + " with transaction.atomic():", + " order = OrderService(cart).place(payload['order_id'])", + " return JsonResponse({'id': order.pk})", + "", + "", + "@require_GET", + "def order_status(request, order_id):", + ]), + vars: { + cart: "", + payload: { order_id: "ord_8812" }, + request: "", + }, + sourceLink: "https://github.com/acme/shop/blob/9c1e2f/shop/views.py#L42", +}); + +const serviceFrame = frame({ + filename: "shop/services.py", + absPath: "/app/shop/services.py", + module: "shop.services", + function: "OrderService.place", + lineNo: 88, + inApp: true, + context: context(83, [ + " def place(self, order_id):", + ' """Turn the cart into an order."""', + " if self.cart.is_empty():", + " raise EmptyCart(self.cart.pk)", + " total = self.cart.total()", + " return Order.objects.create(external_id=order_id, total=total)", + "", + " def cancel(self, order):", + " order.status = Order.CANCELLED", + " order.save(update_fields=['status'])", + "", + ]), + vars: { + order_id: "'ord_8812'", + self: "", + total: "Decimal('129.00')", + }, +}); + +const psycopgFrame = frame({ + filename: "django/db/backends/utils.py", + absPath: + "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", + module: "django.db.backends.utils", + function: "_execute", + lineNo: 105, + context: context(100, [ + " with self.db.wrap_database_errors:", + " if params is None:", + " # params default might be backend specific.", + " return self.cursor.execute(sql)", + " else:", + " return self.cursor.execute(sql, params)", + "", + " def _executemany(self, sql, param_list, *ignored_wrapper_args):", + " # Raise a warning during app initialization (stored_app_configs is only", + " # non-empty during checks).", + " if self.db.apps.ready and self.db.apps.stored_app_configs:", + ]), + vars: { + params: "('ord_8812', Decimal('129.00'))", + sql: '\'INSERT INTO "shop_order" ("external_id", "total") VALUES (%s, %s)\'', + }, +}); + +const djangoErrorEvent = { + id: EVENT_ID, + groupID: GROUP_ID, + eventID: EVENT_ID, + projectID: "4509062593708032", + size: 14823, + type: "error", + platform: "python", + title: + 'IntegrityError: duplicate key value violates unique constraint "shop_order_external_id_key"', + message: "", + culprit: "shop.services in OrderService.place", + location: "shop/services.py", + dateCreated: "2026-11-05T21:14:03.118000Z", + dateReceived: "2026-11-05T21:14:03.902131Z", + dist: null, + crashFile: null, + fingerprints: ["f3b0c2d1e4a59687a1b2c3d4e5f60718"], + groupingConfig: { + id: "newstyle:2023-01-11", + enhancements: "KLUv_SAYwQAAkwKRs25ld3N0eWxlOjIwMjMtMDEtMTGQ", + }, + release: null, + userReport: null, + sdkUpdates: [], + resolvedWith: [], + nextEventID: null, + previousEventID: "9a8b7c6d5e4f30211a2b3c4d5e6f7081", + occurrence: null, + entries: [ + { + type: "exception", + data: { + // the chain cause comes first and the exception that was raised last + values: [ + { + type: "UniqueViolation", + value: + 'duplicate key value violates unique constraint "shop_order_external_id_key"\nDETAIL: Key (external_id)=(ord_8812) already exists.', + module: "psycopg2.errors", + mechanism: { + type: "chained", + handled: true, + source: "__cause__", + exception_id: 1, + parent_id: 0, + }, + threadId: null, + stacktrace: { + frames: [psycopgFrame], + framesOmitted: null, + registers: null, + hasSystemFrames: true, + }, + rawStacktrace: null, + }, + { + type: "IntegrityError", + value: + 'duplicate key value violates unique constraint "shop_order_external_id_key"\nDETAIL: Key (external_id)=(ord_8812) already exists.', + module: "django.db.utils", + mechanism: { + type: "django", + handled: false, + exception_id: 0, + is_exception_group: false, + }, + threadId: null, + stacktrace: { + frames: [djangoFrame, viewFrame, serviceFrame], + framesOmitted: null, + registers: null, + hasSystemFrames: true, + }, + rawStacktrace: null, + }, + ], + hasSystemFrames: true, + excOmitted: null, + }, + }, + { + type: "request", + data: { + apiTarget: null, + method: "POST", + url: "https://shop.example.com/api/orders/", + query: [["source", "cart"]], + fragment: "", + data: { order_id: "ord_8812" }, + headers: [ + ["Authorization", "Bearer sk_live_must_not_leak"], + ["Content-Type", "application/json"], + ["User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)"], + ], + cookies: [["sessionid", "session-cookie-must-not-leak"]], + env: { SERVER_NAME: "web-7f9c", SERVER_PORT: "8000" }, + inferredContentType: "application/json", + }, + }, + ], + user: { + id: "1182", + email: "jane@example.com", + username: "jane", + ip_address: "203.0.113.24", + name: null, + geo: { country_code: "US", city: "San Francisco", region: "CA" }, + data: { plan: "business", is_staff: false }, + }, + tags: [ + { key: "browser", value: "Chrome 130.0.0" }, + { key: "environment", value: "production" }, + { key: "handled", value: "no" }, + { key: "level", value: "error" }, + { key: "mechanism", value: "django" }, + { key: "release", value: "shop@2026.11.5" }, + { key: "server_name", value: "web-7f9c" }, + { key: "transaction", value: "/api/orders/" }, + { key: "url", value: "https://shop.example.com/api/orders/" }, + { key: "user", value: "id:1182", query: 'user.id:"1182"' }, + ], + contexts: { + browser: { name: "Chrome", version: "130.0.0", type: "browser" }, + runtime: { + name: "CPython", + version: "3.12.4", + build: "3.12.4 (main, Jun 13 2024, 04:22:31) [GCC 12.2.0]", + type: "runtime", + }, + trace: { + trace_id: TRACE_ID, + span_id: "a1b2c3d4e5f60718", + parent_span_id: null, + op: "http.server", + status: "internal_error", + origin: "auto.http.django", + type: "trace", + }, + }, + context: { + "sys.argv": ["/usr/local/bin/gunicorn", "shop.wsgi", "--workers", "4"], + }, + sdk: { name: "sentry.python.django", version: "2.18.0" }, + packages: { django: "5.0.6", psycopg2: "2.9.9" }, + errors: [], + metadata: { + type: "IntegrityError", + value: + 'duplicate key value violates unique constraint "shop_order_external_id_key"', + filename: "shop/services.py", + function: "OrderService.place", + }, + _meta: { user: { ip_address: { "": { rem: [["@ip", "s"]] } } } }, +}; + +const djangoIssue = { + ...issueFixture, + id: GROUP_ID, + shortId: "SHOP-4F2", + title: djangoErrorEvent.title, + culprit: djangoErrorEvent.culprit, + permalink: `https://${ORG}.sentry.io/issues/${GROUP_ID}/`, + platform: "python", + project: { + id: "4509062593708032", + name: "shop", + slug: "shop", + platform: "python-django", + }, + metadata: djangoErrorEvent.metadata, + shareId: "share-token-must-not-leak", + count: "312", + userCount: 41, + firstSeen: "2026-11-01T08:02:11.000000Z", + lastSeen: "2026-11-05T21:14:03.118000Z", +}; + +function mockDjangoIssue(event: Record = djangoErrorEvent) { + mswServer.use( + http.get( + `https://sentry.io/api/0/organizations/${ORG}/issues/SHOP-4F2/`, + () => HttpResponse.json(djangoIssue), + ), + http.get( + `https://sentry.io/api/0/organizations/${ORG}/issues/${GROUP_ID}/events/latest/`, + () => HttpResponse.json(event), + ), + http.get( + `https://sentry.io/api/0/organizations/${ORG}/issues/${GROUP_ID}/external-issues/`, + () => + HttpResponse.json([ + { + id: "88112", + issueId: GROUP_ID, + serviceType: "github", + displayName: "acme/shop#311", + webUrl: "https://github.com/acme/shop/issues/311", + integrationId: "integration-id-must-not-leak", + }, + ]), + ), + http.get(`https://sentry.io/api/0/organizations/${ORG}/replay-count/`, () => + HttpResponse.json({}), + ), + http.get(`https://sentry.io/api/0/organizations/${ORG}/events/`, () => + HttpResponse.json({ data: [], meta: { fields: {} } }), + ), + http.get( + `https://sentry.io/api/0/projects/${ORG}/shop/stacktrace-link/`, + () => + HttpResponse.json({ + config: null, + sourceUrl: null, + error: "no_code_mappings_for_project", + }), + ), + http.get( + `https://sentry.io/api/0/projects/${ORG}/shop/events/${EVENT_ID}/committers/`, + () => HttpResponse.json({ committers: [] }), + ), + http.get( + `https://sentry.io/api/0/organizations/${ORG}/issues/SHOP-4F2/autofix/`, + () => HttpResponse.json({ autofix: null }), + ), + ); +} + +const params = { + organizationSlug: ORG, + issueId: "SHOP-4F2", + eventId: undefined, + issueUrl: undefined, + regionUrl: null, +}; + +const experimentalContext = { + constraints: { organizationSlug: undefined }, + accessToken: "access-token", + userId: "1", + experimentalMode: true, +}; + +describe("get_issue_details structuredContent for a realistic error", () => { + it("maps the whole event and satisfies the schema", async () => { + mockDjangoIssue(); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent(result); + + expect(getIssueDetailsOutputSchema.parse(payload)).toEqual(payload); + expect(payload).toMatchInlineSnapshot(` + { + "aiConversations": null, + "codeLocation": null, + "event": { + "contexts": { + "browser": { + "name": "Chrome", + "version": "130.0.0", + }, + "runtime": { + "build": "3.12.4 (main, Jun 13 2024, 04:22:31) [GCC 12.2.0]", + "name": "CPython", + "version": "3.12.4", + }, + "trace": { + "op": "http.server", + "origin": "auto.http.django", + "span_id": "a1b2c3d4e5f60718", + "status": "internal_error", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + }, + }, + "crashedThread": null, + "csp": null, + "evidence": null, + "exceptions": [ + { + "mechanism": { + "handled": false, + "type": "django", + }, + "module": "django.db.utils", + "stacktrace": { + "frames": [ + { + "absPath": "/usr/local/lib/python3.12/site-packages/django/core/handlers/base.py", + "colNo": null, + "contextLine": " response = wrapped_callback(request, *callback_args, **callback_kwargs)", + "filename": "django/core/handlers/base.py", + "function": "_get_response", + "inApp": false, + "lineNo": 197, + "module": "django.core.handlers.base", + "package": null, + "postContext": null, + "preContext": null, + "vars": null, + }, + { + "absPath": "/app/shop/views.py", + "colNo": null, + "contextLine": " order = OrderService(cart).place(payload['order_id'])", + "filename": "shop/views.py", + "function": "checkout", + "inApp": true, + "lineNo": 42, + "module": "shop.views", + "package": null, + "postContext": [ + " return JsonResponse({'id': order.pk})", + "", + "", + ], + "preContext": [ + " cart = Cart.objects.get(user=request.user)", + " payload = json.loads(request.body)", + " with transaction.atomic():", + ], + "vars": { + "cart": "", + "payload": { + "order_id": "ord_8812", + }, + "request": "", + }, + }, + { + "absPath": "/app/shop/services.py", + "colNo": null, + "contextLine": " return Order.objects.create(external_id=order_id, total=total)", + "filename": "shop/services.py", + "function": "OrderService.place", + "inApp": true, + "lineNo": 88, + "module": "shop.services", + "package": null, + "postContext": [ + "", + " def cancel(self, order):", + " order.status = Order.CANCELLED", + ], + "preContext": [ + " if self.cart.is_empty():", + " raise EmptyCart(self.cart.pk)", + " total = self.cart.total()", + ], + "vars": { + "order_id": "'ord_8812'", + "self": "", + "total": "Decimal('129.00')", + }, + }, + ], + "framesOmitted": null, + }, + "type": "IntegrityError", + "value": "duplicate key value violates unique constraint "shop_order_external_id_key" + DETAIL: Key (external_id)=(ord_8812) already exists.", + }, + { + "mechanism": { + "handled": true, + "type": "chained", + }, + "module": "psycopg2.errors", + "stacktrace": { + "frames": [ + { + "absPath": "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", + "colNo": null, + "contextLine": " return self.cursor.execute(sql, params)", + "filename": "django/db/backends/utils.py", + "function": "_execute", + "inApp": false, + "lineNo": 105, + "module": "django.db.backends.utils", + "package": null, + "postContext": null, + "preContext": null, + "vars": null, + }, + ], + "framesOmitted": null, + }, + "type": "UniqueViolation", + "value": "duplicate key value violates unique constraint "shop_order_external_id_key" + DETAIL: Key (external_id)=(ord_8812) already exists.", + }, + ], + "extra": { + "sys.argv": [ + "/usr/local/bin/gunicorn", + "shop.wsgi", + "--workers", + "4", + ], + }, + "id": "0f6c5d7e1b2a4c3d9e8f7a6b5c4d3e2f", + "message": null, + "metricAlert": null, + "occurredAt": "2026-11-05T21:14:03.118Z", + "performance": null, + "platform": "python", + "request": { + "method": "POST", + "url": "https://shop.example.com/api/orders/", + }, + "tags": [ + { + "key": "browser", + "value": "Chrome 130.0.0", + }, + { + "key": "environment", + "value": "production", + }, + { + "key": "handled", + "value": "no", + }, + { + "key": "level", + "value": "error", + }, + { + "key": "mechanism", + "value": "django", + }, + { + "key": "release", + "value": "shop@2026.11.5", + }, + { + "key": "server_name", + "value": "web-7f9c", + }, + { + "key": "transaction", + "value": "/api/orders/", + }, + { + "key": "url", + "value": "https://shop.example.com/api/orders/", + }, + { + "key": "user", + "value": "id:1182", + }, + ], + "threads": null, + "type": "error", + "user": { + "data": { + "is_staff": false, + "plan": "business", + }, + "email": "jane@example.com", + "geo": { + "city": "San Francisco", + "countryCode": "US", + "region": "CA", + }, + "id": "1182", + "ipAddress": "203.0.113.24", + "name": null, + "username": "jane", + }, + }, + "externalIssues": [ + { + "displayName": "acme/shop#311", + "serviceType": "github", + "webUrl": "https://github.com/acme/shop/issues/311", + }, + ], + "issue": { + "assignedTo": "Jane Developer", + "culprit": "shop.services in OrderService.place", + "firstSeen": "2026-11-01T08:02:11.000000Z", + "issueCategory": "error", + "issueType": "error", + "lastSeen": "2026-11-05T21:14:03.118000Z", + "location": null, + "occurrences": 312, + "platform": "python", + "project": "shop", + "queryPattern": null, + "seerActionability": null, + "shortId": "SHOP-4F2", + "status": "unresolved", + "substatus": "ongoing", + "title": "IntegrityError: duplicate key value violates unique constraint "shop_order_external_id_key"", + "url": "https://sentry-mcp-evals.sentry.io/issues/SHOP-4F2", + "usersImpacted": 41, + }, + "replays": null, + "seer": null, + "suggestedActions": [ + { + "arguments": { + "organizationSlug": "sentry-mcp-evals", + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + }, + "reason": "Inspect the distributed trace this event belongs to.", + "toolName": "get_trace_details", + "type": "tool_call", + }, + { + "arguments": { + "issueUrl": "https://sentry-mcp-evals.sentry.io/issues/SHOP-4F2", + }, + "reason": "Read the breadcrumb trail leading up to this event.", + "toolName": "get_issue_breadcrumbs", + "type": "tool_call", + }, + ], + "suspectCommit": null, + } + `); + }); + + it("keeps internal ids, secrets and raw blobs out", async () => { + mockDjangoIssue(); + + const result = await getIssueDetails.handler(params, experimentalContext); + const serialized = JSON.stringify(getStructuredContent(result)); + + for (const leak of [ + // request plumbing the payload does not surface + "sk_live_must_not_leak", + "session-cookie-must-not-leak", + "SERVER_PORT", + // integration and sharing plumbing + "share-token-must-not-leak", + "integration-id-must-not-leak", + "88112", + // frame and event internals + "sourceLink", + "github.com/acme/shop/blob", + "instructionAddr", + "rawStacktrace", + "hasSystemFrames", + "exception_id", + "groupingConfig", + "KLUv_SAYwQAAkwKRs25ld3N0eWxlOjIwMjMtMDEtMTGQ", + "f3b0c2d1e4a59687a1b2c3d4e5f60718", + "_meta", + "4509062593708032", + // a tag's search query is ui plumbing + 'user.id:\\"1182\\"', + ]) { + expect(serialized).not.toContain(leak); + } + }); + + it("returns the stored user data", async () => { + mockDjangoIssue(); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent>(result); + + expect(payload.event.user).toEqual({ + id: "1182", + email: "jane@example.com", + username: "jane", + ipAddress: "203.0.113.24", + name: null, + geo: { countryCode: "US", city: "San Francisco", region: "CA" }, + data: { plan: "business", is_staff: false }, + }); + }); + + it("puts the raised exception first and trims library frame context", async () => { + mockDjangoIssue(); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent>(result); + const [raised, cause] = payload.event.exceptions; + + expect(raised.type).toBe("IntegrityError"); + expect(cause.type).toBe("UniqueViolation"); + const frames = raised.stacktrace.frames; + // oldest call first, the raising frame last + expect(frames.map((f: { function: string }) => f.function)).toEqual([ + "_get_response", + "checkout", + "OrderService.place", + ]); + // a library frame keeps only its own line and no locals + expect(frames[0]).toMatchObject({ + contextLine: + " response = wrapped_callback(request, *callback_args, **callback_kwargs)", + preContext: null, + postContext: null, + vars: null, + }); + // an in-app frame keeps 3 lines either side and its locals + expect(frames[2]).toMatchObject({ + contextLine: + " return Order.objects.create(external_id=order_id, total=total)", + preContext: [ + " if self.cart.is_empty():", + " raise EmptyCart(self.cart.pk)", + " total = self.cart.total()", + ], + postContext: [ + "", + " def cancel(self, order):", + " order.status = Order.CANCELLED", + ], + }); + expect(frames[2].vars).toHaveProperty("order_id", "'ord_8812'"); + }); + + it("turns empty strings into null", async () => { + mockDjangoIssue({ + ...djangoErrorEvent, + tags: [...djangoErrorEvent.tags, { key: "device", value: "" }], + contexts: { + ...djangoErrorEvent.contexts, + os: { name: "", version: "", type: "os" }, + }, + }); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent>(result); + + // the top level message is an empty string on an error event + expect(payload.event.message).toBeNull(); + expect(payload.event.tags).toContainEqual({ key: "device", value: null }); + // a context with nothing left is dropped + expect(payload.event.contexts).not.toHaveProperty("os"); + }); + + it("lists the threads and maps the crashed one when there is no exception", async () => { + // a watchdog termination: threads, but no exception entry + mockDjangoIssue({ + ...djangoErrorEvent, + type: "default", + platform: "native", + entries: [ + { + type: "threads", + data: { + values: [ + { + id: 0, + current: false, + crashed: true, + name: "main", + state: "BLOCKED", + heldLocks: null, + stacktrace: { + frames: [ + frame({ + filename: "main.c", + function: "main", + lineNo: 12, + inApp: true, + }), + frame({ + filename: "render.c", + function: "draw_frame", + lineNo: 220, + inApp: true, + instructionAddr: "0x0000000100f3c2a8", + }), + ], + framesOmitted: null, + registers: { pc: "0x0000000100f3c2a8" }, + hasSystemFrames: false, + }, + }, + { + id: 7, + current: false, + crashed: false, + name: "", + state: null, + heldLocks: null, + stacktrace: null, + }, + ], + }, + }, + ], + }); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getIssueDetailsOutputSchema.parse( + getStructuredContent(result), + ); + + expect(payload.event.exceptions).toBeNull(); + expect(payload.event.threads).toEqual([ + { + id: 0, + name: "main", + state: "BLOCKED", + crashed: true, + current: false, + frameCount: 2, + }, + { + id: 7, + name: null, + state: null, + crashed: false, + current: false, + frameCount: 0, + }, + ]); + expect( + payload.event.crashedThread?.stacktrace?.frames.map((f) => f.function), + ).toEqual(["main", "draw_frame"]); + expect(JSON.stringify(payload)).not.toContain("0x0000000100f3c2a8"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index 7eeaf2e5d..2b90d5f7a 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -2189,30 +2189,37 @@ describe("structuredContent", () => { expect(result).toContain("CLOUDFLARE-MCP-41"); }); - it("renders the event body itself rather than asking the api for one", async () => { + it("maps the event entries into fields rather than rendering markdown", async () => { mockLatestEvent(); const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; - expect(typeof payload.event.body).toBe("string"); - expect(payload.event.body).toContain("### Error"); - expect(payload.event.body).toContain("Something went wrong"); - expect(payload.event.body).toContain("### Tags"); + expect(payload.event).not.toHaveProperty("body"); + expect(payload.event.message).toBe("Something went wrong"); + expect(payload.event.tags).toEqual([ + { key: "level", value: "error" }, + { key: "environment", value: "production" }, + ]); }); - it("carries the response notes, which say which tool to call next", async () => { + it("offers the follow-up calls the markdown notes describe as actions", async () => { mockLatestEvent(); const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; - expect(payload.responseNotes.length).toBeGreaterThan(0); - const notes = payload.responseNotes.join("\n"); - expect(notes).toContain("Fixes CLOUDFLARE-MCP-41"); - expect(notes).toContain("search_issue_events"); + expect(payload).not.toHaveProperty("responseNotes"); + expect(payload.suggestedActions).toContainEqual( + expect.objectContaining({ + toolName: "get_issue_breadcrumbs", + arguments: { + issueUrl: expect.stringContaining("CLOUDFLARE-MCP-41"), + }, + }), + ); }); it("carries the top level message, which the body does not render", async () => { @@ -2225,7 +2232,7 @@ describe("structuredContent", () => { expect(payload.event.message).toBe("TOP-LEVEL-MESSAGE"); }); - it("keeps an unsupported event type on the markdown path", async () => { + it("returns structured content for an unsupported event type", async () => { mswServer.use( http.get( "https://sentry.io/api/0/organizations/*/issues/7777777777/events/latest/", @@ -2237,16 +2244,27 @@ describe("structuredContent", () => { ), ); + const logIssueSpy = vi.spyOn(logging, "logIssue"); const result = await getIssueDetails.handler( { ...params, issueId: "FUTURE-TYPE-001" }, experimentalContext, ); + const payload = getStructuredContent>(result); - expect(result).not.toHaveProperty("structuredContent"); - expect(result).toContain('Unsupported event type "future_ai_agent_trace"'); + expect(() => getIssueDetailsOutputSchema.parse(payload)).not.toThrow(); + expect(payload.issue.shortId).toBe("FUTURE-TYPE-001"); + expect(payload.event.type).toBe("future_ai_agent_trace"); + expect(payload.event.exceptions).toBeNull(); + expect(payload.event.tags).toEqual([{ key: "level", value: "error" }]); + // still reported, so support for the type gets added + expect(logIssueSpy).toHaveBeenCalledWith( + "Unsupported event type encountered: future_ai_agent_trace", + expect.anything(), + ); + logIssueSpy.mockRestore(); }); - it("renders a transaction, whose body carries the performance trace", async () => { + it("maps a transaction's N+1 evidence and its spans", async () => { mswServer.use( http.get( "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/", @@ -2256,6 +2274,10 @@ describe("structuredContent", () => { "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/7890123456/events/latest/", () => HttpResponse.json(createPerformanceEvent()), ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/", + () => HttpResponse.json(createTraceResponseFixture()), + ), ); const result = await getIssueDetails.handler( @@ -2266,7 +2288,51 @@ describe("structuredContent", () => { .structuredContent; expect(payload.event.type).toBe("transaction"); - expect(payload.event.body).toContain("Span"); + expect(() => getIssueDetailsOutputSchema.parse(payload)).not.toThrow(); + expect(payload.event.performance).toMatchObject({ + issueType: "performance_n_plus_one_db_queries", + transactionName: "/api/users", + parentOperation: "GET /api/users", + repeatCount: 3, + repeatedOperations: expect.arrayContaining([ + expect.stringContaining("SELECT"), + ]), + }); + // the span tree from the fetched trace, parents linked by id + expect(payload.event.performance.spans).toEqual([ + { + spanId: "parent123", + parentSpanId: null, + op: "http.server", + description: "GET /api/users", + durationMs: 250, + isOffender: false, + }, + { + spanId: "span001", + parentSpanId: "parent123", + op: "db.query", + description: "SELECT * FROM users WHERE id = 1", + durationMs: 5, + isOffender: true, + }, + { + spanId: "span002", + parentSpanId: "parent123", + op: "db.query", + description: "SELECT * FROM users WHERE id = 2", + durationMs: 5, + isOffender: true, + }, + { + spanId: "span003", + parentSpanId: "parent123", + op: "db.query", + description: "SELECT * FROM users WHERE id = 3", + durationMs: 5, + isOffender: true, + }, + ]); }); it("keeps the attached replay, which lives on the event not the related list", async () => { @@ -2296,7 +2362,7 @@ describe("structuredContent", () => { expect(payload.replays?.related).not.toContain( "1234567890abcdef1234567890abcdef", ); - expect(payload.event.body).not.toContain( + expect(JSON.stringify(payload.event)).not.toContain( "1234567890abcdef1234567890abcdef", ); }); @@ -2758,26 +2824,21 @@ describe("selected event package versions", () => { ], }); expect(JSON.stringify(result)).not.toContain("unrelated"); - expect(payload.event.body).toContain("Something went wrong"); + expect(payload.event.message).toBe("Something went wrong"); expect(payload).toMatchInlineSnapshot(` { "aiConversations": null, "codeLocation": null, "event": { - "body": "### Error - - \`\`\` - Something went wrong - \`\`\` - - ### Tags - - **level**: \`error\` - **environment**: \`production\` - - ", + "contexts": null, + "crashedThread": null, + "csp": null, + "evidence": null, + "exceptions": null, + "extra": null, "id": "7ca573c0f4814912aaa9bdc77d1a7d51", "message": "Something went wrong", + "metricAlert": null, "occurredAt": "2025-10-02T12:00:00.000Z", "packageVersions": { "metadataAvailable": true, @@ -2802,7 +2863,22 @@ describe("selected event package versions", () => { }, ], }, + "performance": null, + "platform": "python", + "request": null, + "tags": [ + { + "key": "level", + "value": "error", + }, + { + "key": "environment", + "value": "production", + }, + ], + "threads": null, "type": "default", + "user": null, }, "externalIssues": null, "issue": { @@ -2826,13 +2902,17 @@ describe("selected event package versions", () => { "usersImpacted": 1, }, "replays": null, - "responseNotes": [ - "Commit message issue reference: \`Fixes CLOUDFLARE-MCP-41\` automatically closes the issue when the commit is merged.", - "The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage.", - "Issue event search: Use the Sentry tool \`search_issue_events\`", - "Breadcrumb trail leading up to this error: Use the Sentry tool \`get_issue_breadcrumbs\`", - ], "seer": null, + "suggestedActions": [ + { + "arguments": { + "issueUrl": "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + }, + "reason": "Read the breadcrumb trail leading up to this event.", + "toolName": "get_issue_breadcrumbs", + "type": "tool_call", + }, + ], "suspectCommit": null, } `); diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.ts index 5163a6dbd..8c62f137d 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -15,21 +15,20 @@ import type { import { ConfigurationError, UserInputError } from "../../errors"; import type { CodeLocation } from "../../internal/code-location"; import { - SelectedEventPackagesSchema, - selectEventPackages, -} from "../../internal/event-packages"; -import { - buildIssueResponseNotes, dedupeReplayIds, - formatEventOutput, + getMetricAlertContext, + getMetricAlertFollowUpCalls, getReplayIdFromEvent, getSeerActionabilityLabel, getSuspectCommit, isPerformanceIssueType, isSupportedEventType, - stripReplayMetadata, + reportUnsupportedEventType, } from "../../internal/formatting"; -import type { AIConversationReference } from "../../internal/tool-helpers/ai-conversation-actions"; +import { + type AIConversationReference, + getAIConversationSuggestedActions, +} from "../../internal/tool-helpers/ai-conversation-actions"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; import { enhanceNotFoundError } from "../../internal/tool-helpers/enhance-error"; @@ -39,6 +38,12 @@ import { parseIssueParams, } from "../../internal/tool-helpers/issue"; import { structuredResult } from "../../internal/tool-helpers/results"; +import { + MAX_SUGGESTED_ACTIONS, + type SuggestedToolAction, + buildSuggestedToolAction, + suggestedToolActionSchema, +} from "../../internal/tool-helpers/suggested-actions"; import { getAutofixArtifactSummaries, getStatusDisplayName, @@ -55,6 +60,12 @@ import { logError, logIssue } from "../../telem/logging"; import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; import { resolveCodeLocation } from "../support/code-location"; +import { + buildIssueEventPayload, + issueEventSchema, +} from "../support/issue-event-payload"; +import { nonEmptyString } from "../support/structured-stacktrace"; +import type { StructuredToolOutput } from "../types"; // mirrors MAX_DISPLAY_REPLAYS in the markdown output const MAX_RELATED_REPLAYS = 5; @@ -67,93 +78,82 @@ const TRACE_ID_PATTERN = /^[0-9a-fA-F]{32}$/; * * Every field is mapped explicitly rather than spread from an api response, so a passthrough * upstream schema cannot leak backend-only fields into the public interface. - * - * `event.body` is this server's own markdown rendering of the event. */ export const getIssueDetailsOutputSchema = z.object({ issue: z.object({ shortId: z.string(), title: z.string(), - culprit: z.string().nullish(), - firstSeen: z.string().nullish(), - lastSeen: z.string().nullish(), - occurrences: z.number().nullish(), - usersImpacted: z.number().nullish(), - status: z.string().nullish(), - substatus: z.string().nullish(), - assignedTo: z.string().nullish(), - issueType: z.string().nullish(), - issueCategory: z.string().nullish(), - seerActionability: z.string().nullish(), - platform: z.string().nullish(), - project: z.string().nullish(), + culprit: z.string().nullable(), + firstSeen: z.string().nullable(), + lastSeen: z.string().nullable(), + occurrences: z.number().nullable(), + usersImpacted: z.number().nullable(), + status: z.string().nullable(), + substatus: z.string().nullable(), + assignedTo: z.string().nullable(), + issueType: z.string().nullable(), + issueCategory: z.string().nullable(), + seerActionability: z.string().nullable(), + platform: z.string().nullable(), + project: z.string().nullable(), url: z.string(), - // metadata fields the markdown surfaces for specific issue types - location: z.string().nullish(), - queryPattern: z.string().nullish(), - }), - event: z.object({ - id: z.string(), - type: z.string().nullish(), - occurredAt: z.string().nullish(), - message: z.string().nullish(), - body: z.string(), - packageVersions: SelectedEventPackagesSchema.optional(), + // metadata fields that only performance issues carry + location: z.string().nullable(), + queryPattern: z.string().nullable(), }), + event: issueEventSchema, seer: z .object({ - status: z.string().nullish(), - rootCause: z.string().nullish(), - solution: z.string().nullish(), + status: z.string().nullable(), + rootCause: z.string().nullable(), + solution: z.string().nullable(), }) - .nullish(), + .nullable(), codeLocation: z .object({ - repository: z.string().nullish(), - path: z.string().nullish(), - line: z.number().nullish(), + repository: z.string().nullable(), + path: z.string().nullable(), + line: z.number().nullable(), url: z.string(), }) - .nullish(), + .nullable(), suspectCommit: z .object({ id: z.string(), - message: z.string().nullish(), - author: z.string().nullish(), - suspectCommitType: z.string().nullish(), + message: z.string().nullable(), + author: z.string().nullable(), + suspectCommitType: z.string().nullable(), }) - .nullish(), + .nullable(), replays: z .object({ - attached: z.string().nullish(), + attached: z.string().nullable(), related: z.array(z.string()), // the full count, since `related` is capped relatedCount: z.number(), }) - .nullish(), + .nullable(), externalIssues: z .array( z.object({ - id: z.string(), - issueId: z.string(), serviceType: z.string(), displayName: z.string(), webUrl: z.string(), }), ) - .nullish(), + .nullable(), aiConversations: z .array( z.object({ conversationId: z.string(), - spanId: z.string().nullish(), + spanId: z.string().nullable(), }), ) - .nullish(), - responseNotes: z.array(z.string()), + .nullable(), + suggestedActions: z.array(suggestedToolActionSchema), }); -export type GetIssueDetailsPayload = z.infer< +export type GetIssueDetailsPayload = z.output< typeof getIssueDetailsOutputSchema >; @@ -162,19 +162,6 @@ export type GetIssueDetailsPayload = z.infer< * The attached id lives on the event rather than in the related list, so passing that list * through alone loses the replay for an issue whose only one is attached. */ -/** - * ``dateCreated`` sits on the error, default, generic and csp event types rather than the base - * union, and the markdown path normalizes it to ISO. Read it the same way and tolerate a bad value. - */ -function eventOccurredAt(event: Event): string | null { - const raw = "dateCreated" in event ? event.dateCreated : null; - if (typeof raw !== "string") { - return null; - } - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); -} - function buildReplays( event: Event, relatedReplayIds?: string[], @@ -197,12 +184,21 @@ function buildReplays( type IssueDetailsArgs = Parameters[0]; function issueDetailsResult(args: IssueDetailsArgs) { - if (args.experimentalMode && isSupportedEventType(args.event)) { - return structuredResult(buildIssueDetailsPayload(args)); + if (args.experimentalMode) { + return buildIssueDetailsResult(args); } return formatIssueOutput(args); } +function buildIssueDetailsResult( + args: IssueDetailsArgs, +): StructuredToolOutput { + if (!isSupportedEventType(args.event)) { + reportUnsupportedEventType(args); + } + return structuredResult(buildIssueDetailsPayload(args)); +} + function buildIssueDetailsPayload({ packageNames, organizationSlug, @@ -219,119 +215,203 @@ function buildIssueDetailsPayload({ experimentalMode, availableToolNames, directToolNames, -}: { - packageNames?: string[]; - organizationSlug: string; - issue: Issue; - event: Event; - apiService: SentryApiService; - autofixState?: AutofixRunState; - performanceTrace?: Trace; - externalIssues?: ExternalIssueList; - relatedReplayIds?: string[]; - aiConversations?: AIConversationReference[]; - codeLocation?: CodeLocation; - committers?: CommitterList; - experimentalMode?: boolean; - availableToolNames?: ReadonlySet; - directToolNames?: ReadonlySet; -}): GetIssueDetailsPayload { +}: IssueDetailsArgs): GetIssueDetailsPayload { const autofix = autofixState?.autofix; // the run's own artifacts, not the whole state: an AutofixRunState carries every step and // would dwarf the rest of the payload const summaries = autofix ? getAutofixArtifactSummaries(autofix) : undefined; const isPerf = isPerformanceIssueType(issue) && !!issue.metadata; - const packageVersions = selectEventPackages(event.packages, packageNames); + const suspectCommit = getSuspectCommit(committers); return { issue: { shortId: issue.shortId, // a performance issue's metadata carries the better title, as the markdown path prefers - title: (isPerf ? issue.metadata?.title : null) || issue.title, - culprit: issue.culprit, - firstSeen: issue.firstSeen, - lastSeen: issue.lastSeen, - occurrences: issue.count == null ? null : Number(issue.count), - usersImpacted: issue.userCount == null ? null : Number(issue.userCount), - status: issue.status, - substatus: issue.substatus, - assignedTo: + title: + (isPerf ? nonEmptyString(issue.metadata?.title) : null) ?? issue.title, + culprit: nonEmptyString(issue.culprit), + firstSeen: nonEmptyString(issue.firstSeen), + lastSeen: nonEmptyString(issue.lastSeen), + occurrences: toCount(issue.count), + usersImpacted: toCount(issue.userCount), + status: nonEmptyString(issue.status), + substatus: nonEmptyString(issue.substatus), + assignedTo: nonEmptyString( typeof issue.assignedTo === "string" ? issue.assignedTo : issue.assignedTo?.name, - issueType: issue.issueType, - issueCategory: issue.issueCategory, + ), + issueType: nonEmptyString(issue.issueType), + issueCategory: nonEmptyString(issue.issueCategory), seerActionability: issue.seerFixabilityScore == null ? null : getSeerActionabilityLabel(issue.seerFixabilityScore), - platform: issue.platform, - project: issue.project?.name, + platform: nonEmptyString(issue.platform), + project: nonEmptyString(issue.project?.name), url: apiService.getIssueUrl(organizationSlug, issue.shortId), // metadata.value is a query pattern only for a performance issue; on an error it is the // exception message, so reading it unconditionally would misname the error text - location: isPerf ? issue.metadata?.location : null, - queryPattern: isPerf ? issue.metadata?.value : null, - }, - event: { - id: event.id, - type: typeof event.type === "string" ? event.type : null, - occurredAt: eventOccurredAt(event), - message: - typeof event.message === "string" && event.message.length > 0 - ? event.message - : null, - // replays are their own field below - body: formatEventOutput(stripReplayMetadata(event), { performanceTrace }), - ...(packageVersions ? { packageVersions } : {}), + location: isPerf ? nonEmptyString(issue.metadata?.location) : null, + queryPattern: isPerf ? nonEmptyString(issue.metadata?.value) : null, }, + event: buildIssueEventPayload({ event, performanceTrace, packageNames }), seer: autofix ? { - status: getStatusDisplayName(autofix.status), - rootCause: summaries?.rootCause, - solution: summaries?.solution, + status: nonEmptyString(getStatusDisplayName(autofix.status)), + rootCause: nonEmptyString(summaries?.rootCause), + solution: nonEmptyString(summaries?.solution), } : null, codeLocation: codeLocation ? { - repository: codeLocation.repository, - path: codeLocation.path, - line: codeLocation.line, + repository: codeLocation.repository ?? null, + path: codeLocation.path ?? null, + line: codeLocation.line ?? null, url: codeLocation.url, } : null, replays: buildReplays(event, relatedReplayIds), - suspectCommit: getSuspectCommit(committers), + suspectCommit: suspectCommit + ? { + id: suspectCommit.id, + message: nonEmptyString(suspectCommit.message), + author: nonEmptyString(suspectCommit.author), + suspectCommitType: nonEmptyString(suspectCommit.suspectCommitType), + } + : null, // mapped field by field, not handed through: several upstream schemas are passthrough, and - // structuredContent is a product contract rather than a view of the api response + // the link's own ids and the Sentry group id are integration plumbing, not follow-up handles externalIssues: externalIssues?.length - ? externalIssues.map((issue) => ({ - id: String(issue.id), - issueId: String(issue.issueId), - serviceType: issue.serviceType, - displayName: issue.displayName, - webUrl: issue.webUrl, + ? externalIssues.map((externalIssue) => ({ + serviceType: externalIssue.serviceType, + displayName: externalIssue.displayName, + webUrl: externalIssue.webUrl, })) : null, aiConversations: aiConversations?.length ? aiConversations.map((conversation) => ({ conversationId: conversation.conversationId, - spanId: conversation.spanId, + spanId: conversation.spanId ?? null, })) : null, - responseNotes: buildIssueResponseNotes({ + suggestedActions: buildIssueSuggestedActions({ organizationSlug, issue, event, apiService, aiConversations, - experimentalMode, - availableToolNames, - directToolNames, + session: { + experimentalMode: experimentalMode ?? false, + availableToolNames, + directToolNames, + }, }), }; } +function toCount(value: unknown): number | null { + if (value == null || value === "") { + return null; + } + const count = Number(value); + return Number.isFinite(count) ? count : null; +} + +/** + * The follow-up calls the markdown Response Notes describe, as callable actions: a metric + * issue's monitor and query, the agent conversations in the trace, the trace itself, and the + * breadcrumbs. + */ +function buildIssueSuggestedActions({ + organizationSlug, + issue, + event, + apiService, + aiConversations, + session, +}: { + organizationSlug: string; + issue: Issue; + event: Event; + apiService: SentryApiService; + aiConversations?: AIConversationReference[]; + session: { + experimentalMode: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; + }; +}): SuggestedToolAction[] { + const candidates: Array = []; + const metricAlertContext = getMetricAlertContext(event); + + if (metricAlertContext) { + const calls = getMetricAlertFollowUpCalls({ + organizationSlug, + issue, + event, + context: metricAlertContext, + }); + if (calls.monitor) { + candidates.push( + buildSuggestedToolAction({ + ...calls.monitor, + reason: "Inspect the Metric Monitor and its connected Alerts.", + ...session, + }), + ); + } + if (calls.search) { + candidates.push( + buildSuggestedToolAction({ + toolName: calls.search.toolName, + arguments: calls.search.arguments, + reason: `Search the data the monitor query matched; focus on ${calls.search.window.start.toISOString()} to ${calls.search.window.end.toISOString()}.`, + ...session, + }), + ); + } + } + + if (aiConversations?.length) { + candidates.push( + ...getAIConversationSuggestedActions({ + organizationSlug, + aiConversations, + ...session, + }), + ); + } + + // a metric issue's trace belongs to the monitor evaluation, not the data that breached + const traceId = getEventTraceId(event); + if (traceId && TRACE_ID_PATTERN.test(traceId) && !metricAlertContext) { + candidates.push( + buildSuggestedToolAction({ + toolName: "get_trace_details", + arguments: { organizationSlug, traceId }, + reason: "Inspect the distributed trace this event belongs to.", + ...session, + }), + ); + } + + candidates.push( + buildSuggestedToolAction({ + toolName: "get_issue_breadcrumbs", + arguments: { + issueUrl: apiService.getIssueUrl(organizationSlug, issue.shortId), + }, + reason: "Read the breadcrumb trail leading up to this event.", + ...session, + }), + ); + + return candidates + .filter((action): action is SuggestedToolAction => action !== null) + .slice(0, MAX_SUGGESTED_ACTIONS); +} + export default defineTool({ name: "get_issue_details", title: "Get Issue Details", @@ -387,10 +467,9 @@ export default defineTool({ issueUrl: ParamIssueUrl.optional(), packageNames: ParamPackageNames.optional(), }, - // outputSchema is deliberately not declared yet. tools/list would export it immediately, - // while a session outside experimental mode still gets a markdown result with - // no structuredContent -- advertising a schema that some success paths cannot satisfy. Wire - // it up once every success path returns structuredContent. + // Experimental sessions get structuredContent on every success path, so only they are told + // the schema; stable sessions still get markdown until the structured payload graduates. + experimentalOutputSchema: getIssueDetailsOutputSchema, annotations: { readOnlyHint: true, destructiveHint: false, diff --git a/packages/mcp-core/src/tools/support/issue-event-payload.ts b/packages/mcp-core/src/tools/support/issue-event-payload.ts new file mode 100644 index 000000000..34171257f --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-event-payload.ts @@ -0,0 +1,489 @@ +import { z } from "zod"; +import { ErrorEntrySchema } from "../../api-client"; +import type { Event, Trace } from "../../api-client/types"; +import { + SelectedEventPackagesSchema, + selectEventPackages, +} from "../../internal/event-packages"; +import { + KNOWN_PERFORMANCE_ISSUE_TYPES, + type MetricAlertContext, + type MetricIssueCondition, + type N1EvidenceData, + OCCURRENCE_TYPE_TO_ISSUE_TYPE, + type PerformanceSpan, + formatMetricAlertCondition, + formatMetricAlertValue, + getMetricAlertContext, + getMetricAlertDatasetKind, + getMetricAlertEvaluatedWindow, + getRepeatingSpanLines, + parseThreadsEntryData, + safeParseInt, + selectN1EvidenceSpans, + stripReplayMetadata, +} from "../../internal/formatting"; +import { isPlainObject } from "../../internal/type-guards"; +import { + type StructuredThread, + mapStructuredStacktrace, + mapStructuredThread, + mapStructuredThreadSummary, + nonEmptyString, + structuredStacktraceSchema, + structuredThreadSchema, + structuredThreadSummarySchema, +} from "./structured-stacktrace"; + +const exceptionSchema = z.object({ + type: z.string().nullable(), + value: z.string().nullable(), + module: z.string().nullable(), + mechanism: z + .object({ type: z.string().nullable(), handled: z.boolean().nullable() }) + .nullable(), + stacktrace: structuredStacktraceSchema.nullable(), +}); + +const evidenceItemSchema = z.object({ name: z.string(), value: z.string() }); + +const performanceSpanSchema = z.object({ + spanId: z.string().nullable(), + parentSpanId: z.string().nullable(), + op: z.string().nullable(), + description: z.string().nullable(), + durationMs: z.number().nullable(), + isOffender: z.boolean(), +}); + +/** + * The event as `structuredContent`. Every field is mapped from the event + * entries Sentry returns; nothing is spread from the api response. + */ +export const issueEventSchema = z.object({ + id: z.string(), + type: z.string().nullable(), + occurredAt: z.string().nullable(), + platform: z.string().nullable(), + message: z.string().nullable(), + exceptions: z + .array(exceptionSchema) + .nullable() + .describe( + "The exception that was raised first, then each one it was chained from.", + ), + threads: z.array(structuredThreadSummarySchema).nullable(), + crashedThread: structuredThreadSchema + .nullable() + .describe("The crashed thread's stacktrace, when there is no exception."), + request: z + .object({ method: z.string().nullable(), url: z.string().nullable() }) + .nullable(), + csp: z + .object({ + blockedUri: z.string().nullable(), + violatedDirective: z.string().nullable(), + effectiveDirective: z.string().nullable(), + documentUri: z.string().nullable(), + sourceFile: z.string().nullable(), + lineNumber: z.number().nullable(), + disposition: z.string().nullable(), + originalPolicy: z.string().nullable(), + }) + .nullable(), + performance: z + .object({ + issueType: z.string().nullable(), + transactionName: z.string().nullable(), + parentOperation: z.string().nullable(), + repeatedOperations: z.array(z.string()), + repeatCount: z.number().nullable(), + spans: z.array(performanceSpanSchema), + }) + .nullable(), + metricAlert: z + .object({ + monitorId: z.string().nullable(), + alertRuleId: z.string().nullable(), + dataset: z.string().nullable(), + aggregate: z.string().nullable(), + query: z.string().nullable(), + intervalSeconds: z.number().nullable(), + environment: z.string().nullable(), + evaluatedWindow: z + .object({ start: z.string(), end: z.string() }) + .nullable(), + value: z.string().nullable(), + thresholds: z.array(z.string()), + }) + .nullable(), + evidence: z.array(evidenceItemSchema).nullable(), + user: z + .object({ + id: z.string().nullable(), + email: z.string().nullable(), + username: z.string().nullable(), + ipAddress: z.string().nullable(), + name: z.string().nullable(), + geo: z + .object({ + countryCode: z.string().nullable(), + city: z.string().nullable(), + region: z.string().nullable(), + }) + .nullable(), + data: z.record(z.string(), z.unknown()).nullable(), + }) + .nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string().nullable() })), + extra: z.record(z.string(), z.unknown()).nullable(), + contexts: z.record(z.string(), z.record(z.string(), z.unknown())).nullable(), + packageVersions: SelectedEventPackagesSchema.optional(), +}); + +export type IssueEventPayload = z.output; + +type Entry = NonNullable[number]; + +function findEntry(event: Event, type: string): Entry | undefined { + return Array.isArray(event.entries) + ? event.entries.find((entry) => entry.type === type) + : undefined; +} + +function record(value: unknown): Record | undefined { + return isPlainObject(value) ? value : undefined; +} + +function hasValue(value: unknown): boolean { + return value !== null && value !== undefined && value !== ""; +} + +/** Copies a user-data map, dropping keys with no value. */ +function compactMap( + value: unknown, + omitKeys: ReadonlySet = new Set(), +): Record | null { + const source = record(value); + if (!source) { + return null; + } + const entries = Object.entries(source).filter( + ([key, entryValue]) => !omitKeys.has(key) && hasValue(entryValue), + ); + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + +/** The event types differ in where `dateCreated` sits; normalize it to ISO and tolerate bad values. */ +function eventOccurredAt(event: Event): string | null { + const raw = "dateCreated" in event ? event.dateCreated : null; + if (typeof raw !== "string") { + return null; + } + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function mapMessage(event: Event): string | null { + const data = record(findEntry(event, "message")?.data); + return ( + nonEmptyString(data?.formatted) ?? + nonEmptyString(data?.message) ?? + nonEmptyString(event.message) + ); +} + +function mapExceptions(event: Event): IssueEventPayload["exceptions"] { + const parsed = ErrorEntrySchema.safeParse( + findEntry(event, "exception")?.data, + ); + if (!parsed.success) { + return null; + } + const { values: chain, value } = parsed.data; + const values = chain ?? (value ? [value] : []); + // Sentry lists the chain cause first; the exception that was raised is last. + const exceptions = values + .filter((exception) => exception != null) + .reverse() + .map((exception) => ({ + type: nonEmptyString(exception.type), + value: nonEmptyString(exception.value), + module: nonEmptyString(exception.module), + mechanism: exception.mechanism + ? { + type: nonEmptyString(exception.mechanism.type), + handled: + typeof exception.mechanism.handled === "boolean" + ? exception.mechanism.handled + : null, + } + : null, + stacktrace: mapStructuredStacktrace(exception.stacktrace), + })); + return exceptions.length > 0 ? exceptions : null; +} + +function mapThreads(event: Event, hasExceptions: boolean) { + const entry = findEntry(event, "threads"); + const threads = entry + ? (parseThreadsEntryData(entry.data)?.values ?? []) + : []; + if (threads.length === 0) { + return { threads: null, crashedThread: null }; + } + // the markdown shows the crashed thread's frames only when there is no exception + const crashed = hasExceptions + ? undefined + : threads.find((thread) => thread.crashed && thread.stacktrace?.frames); + const crashedThread: StructuredThread | null = crashed + ? mapStructuredThread(crashed) + : null; + return { + threads: threads.map(mapStructuredThreadSummary), + crashedThread, + }; +} + +function mapRequest(event: Event): IssueEventPayload["request"] { + const data = record(findEntry(event, "request")?.data); + const method = nonEmptyString(data?.method); + const url = nonEmptyString(data?.url); + return method || url ? { method, url } : null; +} + +function mapCsp(event: Event): IssueEventPayload["csp"] { + const data = record(findEntry(event, "csp")?.data); + if (!data) { + return null; + } + return { + blockedUri: nonEmptyString(data.blocked_uri), + violatedDirective: nonEmptyString(data.violated_directive), + effectiveDirective: nonEmptyString(data.effective_directive), + documentUri: nonEmptyString(data.document_uri), + sourceFile: nonEmptyString(data.source_file), + lineNumber: typeof data.line_number === "number" ? data.line_number : null, + disposition: nonEmptyString(data.disposition), + originalPolicy: nonEmptyString(data.original_policy), + }; +} + +function getOccurrence(event: Event): Record | undefined { + if (event.type !== "transaction" && event.type !== "generic") { + return undefined; + } + return record((event as { occurrence?: unknown }).occurrence); +} + +function mapEvidence( + occurrence: Record | undefined, +): IssueEventPayload["evidence"] { + const display = occurrence?.evidenceDisplay; + if (!Array.isArray(display)) { + return null; + } + // the markdown shows only the items Sentry marks important + const items = display + .map(record) + .filter((item) => item?.important === true) + .flatMap((item) => { + const name = nonEmptyString(item?.name); + const value = nonEmptyString(item?.value); + return name && value ? [{ name, value }] : []; + }); + return items.length > 0 ? items : null; +} + +function flattenSpans( + spans: PerformanceSpan[], + parentSpanId: string | null = null, +): NonNullable["spans"] { + return spans.flatMap((span) => [ + { + spanId: span.span_id, + parentSpanId, + op: span.op, + description: span.description, + durationMs: + span.duration != null && span.duration > 0 + ? Math.round(span.duration * 100) / 100 + : null, + isOffender: span.is_n1_query, + }, + ...flattenSpans(span.children, span.span_id), + ]); +} + +const N1_ISSUE_TYPES: ReadonlySet = new Set([ + KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, + KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, + KNOWN_PERFORMANCE_ISSUE_TYPES.M_N_PLUS_ONE_DB_QUERIES, +]); + +function mapPerformance( + event: Event, + occurrence: Record | undefined, + performanceTrace: Trace | undefined, +): IssueEventPayload["performance"] { + if (event.type !== "transaction" || !occurrence) { + return null; + } + const issueType = + typeof occurrence.type === "number" + ? (OCCURRENCE_TYPE_TO_ISSUE_TYPE[occurrence.type] ?? null) + : (nonEmptyString(occurrence.issueType) ?? + nonEmptyString(occurrence.type)); + const evidenceData = record(occurrence.evidenceData) as + | N1EvidenceData + | undefined; + const isN1 = issueType != null && N1_ISSUE_TYPES.has(issueType); + const repeatCount = + isN1 && evidenceData + ? evidenceData.numberRepeatingSpans + ? safeParseInt(evidenceData.numberRepeatingSpans, 0) + : evidenceData.numPatternRepetitions || + evidenceData.offenderSpanIds?.length || + 0 + : 0; + return { + issueType, + transactionName: nonEmptyString(evidenceData?.transactionName), + parentOperation: nonEmptyString(evidenceData?.parentSpan), + repeatedOperations: + isN1 && evidenceData ? getRepeatingSpanLines(evidenceData) : [], + repeatCount: repeatCount > 0 ? repeatCount : null, + spans: + isN1 && evidenceData + ? flattenSpans( + selectN1EvidenceSpans( + evidenceData, + findEntry(event, "spans")?.data, + performanceTrace, + ), + ) + : [], + }; +} + +function mapMetricAlert( + event: Event, + context: MetricAlertContext | undefined, +): IssueEventPayload["metricAlert"] { + if (!context) { + return null; + } + const { query, evidenceData } = context; + const datasetKind = query ? getMetricAlertDatasetKind(query) : undefined; + const window = getMetricAlertEvaluatedWindow(event, query); + const conditions = Array.isArray(evidenceData.conditions) + ? evidenceData.conditions + : []; + return { + monitorId: context.monitorId ?? null, + alertRuleId: context.alertRuleId ?? null, + // the dataset the monitor's search tool reads (errors, logs, spans, metrics) when known + dataset: datasetKind ?? nonEmptyString(query?.dataset), + aggregate: nonEmptyString(query?.aggregate), + query: nonEmptyString(query?.query), + intervalSeconds: query?.timeWindowSeconds ?? null, + environment: nonEmptyString(query?.environment), + evaluatedWindow: window + ? { start: window.start.toISOString(), end: window.end.toISOString() } + : null, + value: formatMetricAlertValue(evidenceData.value) ?? null, + thresholds: conditions + .filter(isPlainObject) + .map((condition) => + formatMetricAlertCondition( + condition as Partial>, + ), + ) + .filter((condition): condition is string => condition != null), + }; +} + +function mapUser(event: Event): IssueEventPayload["user"] { + const user = record((event as { user?: unknown }).user); + if (!user) { + return null; + } + const geo = record(user.geo); + const mappedGeo = geo + ? { + countryCode: nonEmptyString(geo.country_code), + city: nonEmptyString(geo.city), + region: nonEmptyString(geo.region), + } + : null; + const mapped = { + id: nonEmptyString(user.id), + email: nonEmptyString(user.email), + username: nonEmptyString(user.username), + ipAddress: nonEmptyString(user.ip_address), + name: nonEmptyString(user.name), + geo: + mappedGeo && Object.values(mappedGeo).some((value) => value !== null) + ? mappedGeo + : null, + data: compactMap(user.data), + }; + return Object.values(mapped).some((value) => value !== null) ? mapped : null; +} + +function mapContexts(event: Event): IssueEventPayload["contexts"] { + const contexts = record(event.contexts); + if (!contexts) { + return null; + } + const omitType = new Set(["type"]); + const entries = Object.entries(contexts).flatMap(([name, value]) => { + const mapped = compactMap(value, omitType); + return mapped ? [[name, mapped] as const] : []; + }); + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + +/** Maps an issue event, of any type, to its structured payload. */ +export function buildIssueEventPayload({ + event, + performanceTrace, + packageNames, +}: { + event: Event; + performanceTrace?: Trace; + packageNames?: string[]; +}): IssueEventPayload { + // replays are reported beside the event, not inside its contexts and tags + const stripped = stripReplayMetadata(event); + const exceptions = mapExceptions(stripped); + const { threads, crashedThread } = mapThreads(stripped, exceptions !== null); + const occurrence = getOccurrence(stripped); + const metricAlertContext = getMetricAlertContext(stripped); + const packageVersions = selectEventPackages(event.packages, packageNames); + + return { + id: event.id, + type: nonEmptyString(event.type), + occurredAt: eventOccurredAt(event), + platform: nonEmptyString(event.platform), + message: mapMessage(stripped), + exceptions, + threads, + crashedThread, + request: mapRequest(stripped), + csp: mapCsp(stripped), + performance: mapPerformance(stripped, occurrence, performanceTrace), + metricAlert: mapMetricAlert(stripped, metricAlertContext), + // a metric issue's evidence is the alert itself, which metricAlert carries + evidence: metricAlertContext ? null : mapEvidence(occurrence), + user: mapUser(stripped), + tags: (stripped.tags ?? []).map((tag) => ({ + key: tag.key, + value: nonEmptyString(tag.value), + })), + extra: compactMap(stripped.context), + contexts: mapContexts(stripped), + ...(packageVersions ? { packageVersions } : {}), + }; +} diff --git a/packages/mcp-core/src/tools/support/structured-stacktrace.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.ts new file mode 100644 index 000000000..7d1f316e5 --- /dev/null +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; +import type { FrameInterface, ThreadEntrySchema } from "../../api-client"; + +type Frame = z.infer; +type Thread = z.infer; +type Stacktrace = { + frames: Frame[]; + framesOmitted?: unknown[] | null; +}; + +/** Lines kept on each side of an in-app frame's line, as the markdown frame view shows. */ +const IN_APP_CONTEXT_LINES = 3; + +/** + * One stack frame. Shared by `get_issue_details` and `get_event_stacktrace` so + * both tools describe frames the same way. + */ +export const structuredFrameSchema = z.object({ + filename: z.string().nullable(), + absPath: z.string().nullable(), + module: z.string().nullable(), + package: z.string().nullable(), + function: z.string().nullable(), + lineNo: z.number().nullable(), + colNo: z.number().nullable(), + inApp: z.boolean().nullable(), + contextLine: z.string().nullable().describe("The source line at lineNo."), + preContext: z + .array(z.string()) + .nullable() + .describe("Up to 3 source lines before lineNo, for in-app frames only."), + postContext: z + .array(z.string()) + .nullable() + .describe("Up to 3 source lines after lineNo, for in-app frames only."), + vars: z + .record(z.string(), z.unknown()) + .nullable() + .describe("Local variables, recorded for in-app frames only."), +}); + +export const structuredStacktraceSchema = z.object({ + frames: z + .array(structuredFrameSchema) + .describe("Oldest call first; the frame that raised is last."), + framesOmitted: z.number().nullable(), +}); + +export const structuredThreadSummarySchema = z.object({ + id: z.union([z.number(), z.string()]).nullable(), + name: z.string().nullable(), + state: z.string().nullable(), + crashed: z.boolean(), + current: z.boolean(), + frameCount: z.number(), +}); + +export const structuredThreadSchema = structuredThreadSummarySchema.extend({ + stacktrace: structuredStacktraceSchema.nullable(), +}); + +export type StructuredFrame = z.output; +export type StructuredStacktrace = z.output; +export type StructuredThreadSummary = z.output< + typeof structuredThreadSummarySchema +>; +export type StructuredThread = z.output; + +/** A string worth returning: empty and whitespace-only strings carry nothing. */ +export function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +type FrameContext = Pick< + StructuredFrame, + "contextLine" | "preContext" | "postContext" +>; + +/** Splits Sentry's `[lineNo, line]` context around the frame's own line, as the event protocol does. */ +function mapContext(frame: Frame): FrameContext { + const lineNo = frame.lineNo; + const context = lineNo == null ? [] : (frame.context ?? []); + const contextLine = + context.find(([contextLineNo]) => contextLineNo === lineNo)?.[1] ?? null; + if (frame.inApp !== true || lineNo == null) { + return { contextLine, preContext: null, postContext: null }; + } + const linesWithin = (from: number, to: number) => { + const lines = context + .filter(([contextLineNo]) => contextLineNo >= from && contextLineNo <= to) + .map(([, line]) => line); + return lines.length > 0 ? lines : null; + }; + return { + contextLine, + preContext: linesWithin(lineNo - IN_APP_CONTEXT_LINES, lineNo - 1), + postContext: linesWithin(lineNo + 1, lineNo + IN_APP_CONTEXT_LINES), + }; +} + +/** Maps one upstream frame field by field; raw addresses, symbols and source links stay out. */ +export function mapStructuredFrame(frame: Frame): StructuredFrame { + const vars = + frame.inApp === true && frame.vars && Object.keys(frame.vars).length > 0 + ? { ...frame.vars } + : null; + return { + filename: nonEmptyString(frame.filename), + absPath: nonEmptyString(frame.absPath), + module: nonEmptyString(frame.module), + package: nonEmptyString(frame.package), + function: nonEmptyString(frame.function), + lineNo: finiteNumber(frame.lineNo), + colNo: finiteNumber(frame.colNo), + inApp: typeof frame.inApp === "boolean" ? frame.inApp : null, + ...mapContext(frame), + vars, + }; +} + +/** Sentry records omitted frames as a `[start, end]` index pair. */ +export function countFramesOmitted( + framesOmitted: unknown[] | null | undefined, +): number | null { + const [first, last] = framesOmitted ?? []; + if ( + typeof first === "number" && + typeof last === "number" && + Number.isFinite(first) && + Number.isFinite(last) + ) { + return Math.max(0, last - first); + } + return null; +} + +export function mapStructuredStacktrace( + stacktrace: Stacktrace | null | undefined, +): StructuredStacktrace | null { + if (!stacktrace?.frames?.length) { + return null; + } + return { + frames: stacktrace.frames.map(mapStructuredFrame), + framesOmitted: countFramesOmitted(stacktrace.framesOmitted), + }; +} + +export function mapStructuredThreadSummary( + thread: Thread, +): StructuredThreadSummary { + return { + id: thread.id ?? null, + name: nonEmptyString(thread.name), + state: nonEmptyString(thread.state), + crashed: thread.crashed === true, + current: thread.current === true, + frameCount: thread.stacktrace?.frames?.length ?? 0, + }; +} + +export function mapStructuredThread(thread: Thread): StructuredThread { + return { + ...mapStructuredThreadSummary(thread), + stacktrace: mapStructuredStacktrace(thread.stacktrace), + }; +} diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index 95737b8c6..b407f9336 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -145,6 +145,14 @@ export interface ToolConfig< * `ToolOutputFor`. */ outputSchema?: TOutputSchema; + /** + * Structured result contract advertised only to experimental sessions, for a + * tool whose structured payload has not graduated yet. Set it instead of + * `outputSchema`; the handler must return `structuredResult(...)` matching it + * on every experimental success path. tsc cannot check that, because stable + * sessions still get markdown, so the tool's tests must parse the payload. + */ + experimentalOutputSchema?: ToolOutputSchema; annotations: { // readOnlyHint, destructiveHint, and openWorldHint are required so every // tool declares its safety posture explicitly. Filters and confirmation From 2e410a4f683a6e61f1d8931ce17dcdad03721c2a 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:42:32 +0000 Subject: [PATCH 2/6] feat(mcp): Trim structured stack frames to the fields that carry data Frames now leave out fields with no value, drop absPath when it repeats filename, and give pre/post context and local variables only to the most relevant frame (the last in-app frame of the raised exception or thread), with each variable cut to 200 characters of JSON. Issue details list at most 20 thread summaries beside a threadCount. The test guard now checks experimentalOutputSchema results, and the metadata linter flags an EXEMPT tool as soon as any session gives it a schema while a PENDING tool stays pending until every session does. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- docs/contributing/tool-responses.md | 31 +-- packages/mcp-core/src/server.metadata.test.ts | 11 +- .../src/test-utils/structured-output-guard.ts | 7 +- .../get-issue-details.structured.test.ts | 74 ++++--- .../tools/catalog/get-issue-details.test.ts | 1 + .../src/tools/support/issue-event-payload.ts | 31 ++- .../support/structured-stacktrace.test.ts | 201 ++++++++++++++++++ .../tools/support/structured-stacktrace.ts | 177 +++++++++------ 8 files changed, 411 insertions(+), 122 deletions(-) create mode 100644 packages/mcp-core/src/tools/support/structured-stacktrace.test.ts diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 9284312ac..86c617596 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -151,13 +151,16 @@ In experimental mode `get_issue_details` returns `structuredContent` for every event type, including types the markdown path does not support, and declares `outputSchema` to experimental sessions only (`experimentalOutputSchema`). `event` maps the entries into fields instead of rendering markdown: the -exception chain (the raised exception first), thread summaries, the crashed -thread when there is no exception, request method and URL, CSP report, N+1 -evidence and spans, metric alert, evidence, user (including email and IP), tags, -extra data, and contexts. Frames use the shared shape in -`tools/support/structured-stacktrace.ts`: oldest call first, `contextLine` for -every frame, and `preContext`, `postContext` (3 lines each) and `vars` for -in-app frames only. Request headers, cookies and body, frame addresses, symbols +exception chain (the raised exception first), up to 20 thread summaries with a +`threadCount`, the crashed thread when there is no exception, request method and +URL, CSP report, N+1 evidence and spans, metric alert, evidence, user (including +email and IP), tags, extra data, and contexts. Frames use the shared shape in +`tools/support/structured-stacktrace.ts`: most recent call last, with only the +fields that have a value (`inApp` only when true, `absPath` only when it differs +from `filename`). Every frame gets its `contextLine`. Only the most relevant +frame (the last in-app frame of the raised exception or the thread) gets +`preContext`, `postContext` (3 lines each) and `vars`, with each variable cut +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. ### Profile Details @@ -228,8 +231,9 @@ Policy (encoded in `packages/mcp-core/src/internal/tool-helpers/results.ts`): `suggestedActions` alone on top of markdown. - A tool whose structured payload is still behind experimental mode declares `experimentalOutputSchema` instead. The server advertises it to experimental - sessions only, and tsc cannot check the handler against it, so the tool's - tests must parse every structured payload against the schema. + sessions only, and tsc cannot check the handler against it. The test guard + checks every `structuredContent` result against it, and the tool's tests + must cover every structured path. - If a tool declares `outputSchema`, every successful `structuredContent` result must conform to that schema. `defineTool` enforces this at compile time: the handler must return `structuredResult(...)` typed as the schema's `z.output`, @@ -297,10 +301,11 @@ reason: - `OUTPUT_SCHEMA_PENDING` for tools whose `outputSchema` has not landed, with the PR, for example `"pending getsentry/toolkit#2004"`. -An entry fails once its tool has an `outputSchema` in every session that lists -it, so the PR that adds one also deletes the entry. A tool whose -`experimentalOutputSchema` reaches experimental sessions first stays -`OUTPUT_SCHEMA_PENDING` until it graduates. To add a `_meta` key, add it to `ALLOWED_TOOL_META` +An `OUTPUT_SCHEMA_EXEMPT` entry fails once any session lists its tool with an +`outputSchema`. An `OUTPUT_SCHEMA_PENDING` entry fails once every session that +lists the tool has one, so a tool whose `experimentalOutputSchema` reaches +experimental sessions first stays pending until it graduates. The PR that +makes an entry stale also deletes it. To add a `_meta` key, add it to `ALLOWED_TOOL_META` with a comment that says which client reads it. ## Suggested Actions diff --git a/packages/mcp-core/src/server.metadata.test.ts b/packages/mcp-core/src/server.metadata.test.ts index fdf85250d..641d3e332 100644 --- a/packages/mcp-core/src/server.metadata.test.ts +++ b/packages/mcp-core/src/server.metadata.test.ts @@ -233,8 +233,6 @@ describe.each(Object.keys(MODES))("%s mode", (mode) => { }); describe("outputSchema skip lists", () => { - // a tool can declare its outputSchema to experimental sessions first, so an entry is - // stale only once every surface that lists the tool declares one const listings = (name: string) => [...surfaces.values()].flatMap(({ tools }) => tools.filter((tool) => tool.name === name), @@ -249,7 +247,14 @@ describe("outputSchema skip lists", () => { if (tools.length === 0) { return [`${name}: in OUTPUT_SCHEMA_${list} but not listed`]; } - if (tools.every((tool) => tool.outputSchema)) { + // a PENDING tool can declare its outputSchema to experimental sessions + // first, so it is stale once every surface declares one; an EXEMPT tool + // is stale as soon as any surface does + const stale = + list === "PENDING" + ? tools.every((tool) => tool.outputSchema) + : tools.some((tool) => tool.outputSchema); + if (stale) { return [ `${name}: has an outputSchema now; delete its OUTPUT_SCHEMA_${list} entry`, ]; diff --git a/packages/mcp-core/src/test-utils/structured-output-guard.ts b/packages/mcp-core/src/test-utils/structured-output-guard.ts index f3ccd4633..25ffc3e2c 100644 --- a/packages/mcp-core/src/test-utils/structured-output-guard.ts +++ b/packages/mcp-core/src/test-utils/structured-output-guard.ts @@ -15,7 +15,9 @@ vi.mock("../internal/tool-helpers/define", async (importOriginal) => { const actual = await importOriginal(); const defineTool: typeof actual.defineTool = (config) => { - const { outputSchema } = config; + // an experimentalOutputSchema covers only the sessions that return structuredContent + const experimental = !config.outputSchema; + const outputSchema = config.outputSchema ?? config.experimentalOutputSchema; if (!outputSchema) { return actual.defineTool(config); } @@ -26,6 +28,9 @@ vi.mock("../internal/tool-helpers/define", async (importOriginal) => { return result; } if (typeof result !== "object" || !("structuredContent" in result)) { + if (experimental) { + return result; + } throw new Error( `${config.name} has an outputSchema but returned no structuredContent`, ); diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts index 89bf5a737..87746a9a3 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts @@ -438,56 +438,29 @@ describe("get_issue_details structuredContent for a realistic error", () => { "frames": [ { "absPath": "/usr/local/lib/python3.12/site-packages/django/core/handlers/base.py", - "colNo": null, "contextLine": " response = wrapped_callback(request, *callback_args, **callback_kwargs)", "filename": "django/core/handlers/base.py", "function": "_get_response", - "inApp": false, "lineNo": 197, "module": "django.core.handlers.base", - "package": null, - "postContext": null, - "preContext": null, - "vars": null, }, { "absPath": "/app/shop/views.py", - "colNo": null, "contextLine": " order = OrderService(cart).place(payload['order_id'])", "filename": "shop/views.py", "function": "checkout", "inApp": true, "lineNo": 42, "module": "shop.views", - "package": null, - "postContext": [ - " return JsonResponse({'id': order.pk})", - "", - "", - ], - "preContext": [ - " cart = Cart.objects.get(user=request.user)", - " payload = json.loads(request.body)", - " with transaction.atomic():", - ], - "vars": { - "cart": "", - "payload": { - "order_id": "ord_8812", - }, - "request": "", - }, }, { "absPath": "/app/shop/services.py", - "colNo": null, "contextLine": " return Order.objects.create(external_id=order_id, total=total)", "filename": "shop/services.py", "function": "OrderService.place", "inApp": true, "lineNo": 88, "module": "shop.services", - "package": null, "postContext": [ "", " def cancel(self, order):", @@ -521,17 +494,11 @@ describe("get_issue_details structuredContent for a realistic error", () => { "frames": [ { "absPath": "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", - "colNo": null, "contextLine": " return self.cursor.execute(sql, params)", "filename": "django/db/backends/utils.py", "function": "_execute", - "inApp": false, "lineNo": 105, "module": "django.db.backends.utils", - "package": null, - "postContext": null, - "preContext": null, - "vars": null, }, ], "framesOmitted": null, @@ -601,6 +568,7 @@ describe("get_issue_details structuredContent for a realistic error", () => { "value": "id:1182", }, ], + "threadCount": 0, "threads": null, "type": "error", "user": { @@ -724,7 +692,7 @@ describe("get_issue_details structuredContent for a realistic error", () => { }); }); - it("puts the raised exception first and trims library frame context", async () => { + it("puts the raised exception first and keeps context on the most relevant frame", async () => { mockDjangoIssue(); const result = await getIssueDetails.handler(params, experimentalContext); @@ -744,11 +712,14 @@ describe("get_issue_details structuredContent for a realistic error", () => { expect(frames[0]).toMatchObject({ contextLine: " response = wrapped_callback(request, *callback_args, **callback_kwargs)", - preContext: null, - postContext: null, - vars: null, }); - // an in-app frame keeps 3 lines either side and its locals + expect(frames[0]).not.toHaveProperty("preContext"); + expect(frames[0]).not.toHaveProperty("vars"); + // an earlier in-app frame keeps only its own line too + expect(frames[1]).toHaveProperty("inApp", true); + expect(frames[1]).not.toHaveProperty("preContext"); + expect(frames[1]).not.toHaveProperty("vars"); + // the most relevant (last in-app) frame keeps 3 lines either side and its locals expect(frames[2]).toMatchObject({ contextLine: " return Order.objects.create(external_id=order_id, total=total)", @@ -766,6 +737,33 @@ describe("get_issue_details structuredContent for a realistic error", () => { expect(frames[2].vars).toHaveProperty("order_id", "'ord_8812'"); }); + it("lists the first 20 threads and counts them all", async () => { + const threads = Array.from({ length: 25 }, (_, index) => ({ + id: index + 1, + name: `worker-${index + 1}`, + state: "WAITING", + crashed: false, + current: false, + heldLocks: null, + stacktrace: null, + rawStacktrace: null, + })); + mockDjangoIssue({ + ...djangoErrorEvent, + entries: [ + ...djangoErrorEvent.entries, + { type: "threads", data: { values: threads } }, + ], + }); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent>(result); + + expect(payload.event.threadCount).toBe(25); + expect(payload.event.threads).toHaveLength(20); + expect(payload.event.threads.at(-1).name).toBe("worker-20"); + }); + it("turns empty strings into null", async () => { mockDjangoIssue({ ...djangoErrorEvent, diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index 2b90d5f7a..1846c501c 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -2876,6 +2876,7 @@ describe("selected event package versions", () => { "value": "production", }, ], + "threadCount": 0, "threads": null, "type": "default", "user": null, diff --git a/packages/mcp-core/src/tools/support/issue-event-payload.ts b/packages/mcp-core/src/tools/support/issue-event-payload.ts index 34171257f..4ca02d8d3 100644 --- a/packages/mcp-core/src/tools/support/issue-event-payload.ts +++ b/packages/mcp-core/src/tools/support/issue-event-payload.ts @@ -60,6 +60,9 @@ const performanceSpanSchema = z.object({ * The event as `structuredContent`. Every field is mapped from the event * entries Sentry returns; nothing is spread from the api response. */ +/** Thread summaries listed per event; get_event_stacktrace reads any one thread in full. */ +const MAX_THREAD_SUMMARIES = 20; + export const issueEventSchema = z.object({ id: z.string(), type: z.string().nullable(), @@ -72,7 +75,13 @@ export const issueEventSchema = z.object({ .describe( "The exception that was raised first, then each one it was chained from.", ), - threads: z.array(structuredThreadSummarySchema).nullable(), + threads: z + .array(structuredThreadSummarySchema) + .nullable() + .describe( + `The first ${MAX_THREAD_SUMMARIES} threads; threadCount says how many the event has.`, + ), + threadCount: z.number(), crashedThread: structuredThreadSchema .nullable() .describe("The crashed thread's stacktrace, when there is no exception."), @@ -206,7 +215,7 @@ function mapExceptions(event: Event): IssueEventPayload["exceptions"] { const exceptions = values .filter((exception) => exception != null) .reverse() - .map((exception) => ({ + .map((exception, index) => ({ type: nonEmptyString(exception.type), value: nonEmptyString(exception.value), module: nonEmptyString(exception.module), @@ -219,7 +228,10 @@ function mapExceptions(event: Event): IssueEventPayload["exceptions"] { : null, } : null, - stacktrace: mapStructuredStacktrace(exception.stacktrace), + // like the markdown view, only the raised exception's relevant frame gets context and variables + stacktrace: mapStructuredStacktrace(exception.stacktrace, { + detail: index === 0, + }), })); return exceptions.length > 0 ? exceptions : null; } @@ -230,7 +242,7 @@ function mapThreads(event: Event, hasExceptions: boolean) { ? (parseThreadsEntryData(entry.data)?.values ?? []) : []; if (threads.length === 0) { - return { threads: null, crashedThread: null }; + return { threads: null, threadCount: 0, crashedThread: null }; } // the markdown shows the crashed thread's frames only when there is no exception const crashed = hasExceptions @@ -240,7 +252,10 @@ function mapThreads(event: Event, hasExceptions: boolean) { ? mapStructuredThread(crashed) : null; return { - threads: threads.map(mapStructuredThreadSummary), + threads: threads + .slice(0, MAX_THREAD_SUMMARIES) + .map(mapStructuredThreadSummary), + threadCount: threads.length, crashedThread, }; } @@ -457,7 +472,10 @@ export function buildIssueEventPayload({ // replays are reported beside the event, not inside its contexts and tags const stripped = stripReplayMetadata(event); const exceptions = mapExceptions(stripped); - const { threads, crashedThread } = mapThreads(stripped, exceptions !== null); + const { threads, threadCount, crashedThread } = mapThreads( + stripped, + exceptions !== null, + ); const occurrence = getOccurrence(stripped); const metricAlertContext = getMetricAlertContext(stripped); const packageVersions = selectEventPackages(event.packages, packageNames); @@ -470,6 +488,7 @@ export function buildIssueEventPayload({ message: mapMessage(stripped), exceptions, threads, + threadCount, crashedThread, request: mapRequest(stripped), csp: mapCsp(stripped), diff --git a/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts new file mode 100644 index 000000000..4b6d5518b --- /dev/null +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + mapStructuredStacktrace, + structuredStacktraceSchema, +} from "./structured-stacktrace"; + +// Frames as Sentry's event API returns them: every key present, unset ones null. +function sentryFrame(overrides: Record) { + return { + filename: null, + absPath: null, + module: null, + package: null, + platform: null, + instructionAddr: null, + symbolAddr: null, + function: null, + rawFunction: null, + symbol: null, + context: [], + lineNo: null, + colNo: null, + inApp: false, + trust: null, + errors: null, + lock: null, + sourceLink: null, + vars: null, + ...overrides, + }; +} + +const context = (lineNo: number) => + [-3, -2, -1, 0, 1, 2, 3, 4].map( + (offset) => + [lineNo + offset, `line ${lineNo + offset}`] as [number, string], + ); + +describe("mapStructuredStacktrace", () => { + it("writes only the fields that have a value", () => { + const stacktrace = mapStructuredStacktrace({ + frames: [ + sentryFrame({ + filename: "django/core/handlers/base.py", + absPath: "django/core/handlers/base.py", + module: "django.core.handlers.base", + function: "get_response", + lineNo: 181, + colNo: null, + inApp: false, + }), + sentryFrame({ + filename: "", + absPath: "/usr/src/app/checkout.py", + function: "submit", + lineNo: 7, + inApp: true, + }), + ], + framesOmitted: null, + }); + + expect(structuredStacktraceSchema.parse(stacktrace)).toEqual(stacktrace); + expect(stacktrace?.frames).toEqual([ + { + filename: "django/core/handlers/base.py", + module: "django.core.handlers.base", + function: "get_response", + lineNo: 181, + }, + { + absPath: "/usr/src/app/checkout.py", + function: "submit", + lineNo: 7, + inApp: true, + }, + ]); + }); + + it("gives context and vars only to the most relevant in-app frame", () => { + const stacktrace = mapStructuredStacktrace({ + frames: [ + sentryFrame({ + filename: "app/models.py", + function: "save", + lineNo: 10, + inApp: true, + context: context(10), + vars: { self: "" }, + }), + sentryFrame({ + filename: "site-packages/requests/api.py", + function: "post", + lineNo: 20, + inApp: false, + context: context(20), + vars: { url: "https://api.example.com/charge", token: "abc" }, + }), + sentryFrame({ + filename: "app/payments.py", + function: "charge", + lineNo: 30, + inApp: true, + context: context(30), + vars: { amount: 42 }, + }), + sentryFrame({ + filename: "site-packages/urllib3/connection.py", + function: "connect", + lineNo: 40, + inApp: false, + context: context(40), + vars: { host: "api.example.com" }, + }), + ], + framesOmitted: null, + }); + + expect(stacktrace?.frames).toEqual([ + { + filename: "app/models.py", + function: "save", + lineNo: 10, + inApp: true, + contextLine: "line 10", + }, + { + filename: "site-packages/requests/api.py", + function: "post", + lineNo: 20, + contextLine: "line 20", + }, + { + filename: "app/payments.py", + function: "charge", + lineNo: 30, + inApp: true, + contextLine: "line 30", + preContext: ["line 27", "line 28", "line 29"], + postContext: ["line 31", "line 32", "line 33"], + vars: { amount: 42 }, + }, + { + filename: "site-packages/urllib3/connection.py", + function: "connect", + lineNo: 40, + contextLine: "line 40", + }, + ]); + }); + + it("keeps context and vars off every frame when detail is off", () => { + const stacktrace = mapStructuredStacktrace( + { + frames: [ + sentryFrame({ + filename: "app/payments.py", + lineNo: 30, + inApp: true, + context: context(30), + vars: { amount: 42 }, + }), + ], + framesOmitted: null, + }, + { detail: false }, + ); + + expect(stacktrace?.frames).toEqual([ + { + filename: "app/payments.py", + lineNo: 30, + inApp: true, + contextLine: "line 30", + }, + ]); + }); + + it("cuts a large variable to 200 characters of JSON", () => { + const rows = Array.from({ length: 50 }, (_, index) => ({ + id: index, + email: `user${index}@example.com`, + })); + const stacktrace = mapStructuredStacktrace({ + frames: [ + sentryFrame({ + filename: "app/export.py", + lineNo: 5, + inApp: true, + vars: { rows, count: 50, label: "export" }, + }), + ], + framesOmitted: null, + }); + + const vars = stacktrace?.frames[0]?.vars; + expect(vars?.count).toBe(50); + expect(vars?.label).toBe("export"); + expect(vars?.rows).toBe(`${JSON.stringify(rows).slice(0, 200)}…`); + }); +}); diff --git a/packages/mcp-core/src/tools/support/structured-stacktrace.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.ts index 7d1f316e5..349c2326a 100644 --- a/packages/mcp-core/src/tools/support/structured-stacktrace.ts +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { FrameInterface, ThreadEntrySchema } from "../../api-client"; +import { findMostRelevantInAppFrame } from "../../internal/code-location"; type Frame = z.infer; type Thread = z.infer; @@ -8,41 +9,55 @@ type Stacktrace = { framesOmitted?: unknown[] | null; }; -/** Lines kept on each side of an in-app frame's line, as the markdown frame view shows. */ -const IN_APP_CONTEXT_LINES = 3; +/** Lines kept on each side of the detail frame's line, as the markdown frame view shows. */ +const DETAIL_CONTEXT_LINES = 3; + +/** A local variable whose JSON is longer than this is cut to this many characters. */ +const MAX_VAR_LENGTH = 200; /** * One stack frame. Shared by `get_issue_details` and `get_event_stacktrace` so - * both tools describe frames the same way. + * both tools describe frames the same way. Fields with no value are left out. */ export const structuredFrameSchema = z.object({ - filename: z.string().nullable(), - absPath: z.string().nullable(), - module: z.string().nullable(), - package: z.string().nullable(), - function: z.string().nullable(), - lineNo: z.number().nullable(), - colNo: z.number().nullable(), - inApp: z.boolean().nullable(), - contextLine: z.string().nullable().describe("The source line at lineNo."), + filename: z.string().optional(), + absPath: z + .string() + .optional() + .describe("Present only when it differs from filename."), + module: z.string().optional(), + package: z.string().optional(), + function: z.string().optional(), + lineNo: z.number().optional(), + colNo: z.number().optional(), + inApp: z.literal(true).optional().describe("Present on application frames."), + contextLine: z.string().optional().describe("The source line at lineNo."), preContext: z .array(z.string()) - .nullable() - .describe("Up to 3 source lines before lineNo, for in-app frames only."), + .optional() + .describe( + "Up to 3 source lines before lineNo, on the most relevant frame only.", + ), postContext: z .array(z.string()) - .nullable() - .describe("Up to 3 source lines after lineNo, for in-app frames only."), + .optional() + .describe( + "Up to 3 source lines after lineNo, on the most relevant frame only.", + ), vars: z .record(z.string(), z.unknown()) - .nullable() - .describe("Local variables, recorded for in-app frames only."), + .optional() + .describe( + "Local variables, on the most relevant frame only. Values longer than 200 characters of JSON are cut to a string.", + ), }); export const structuredStacktraceSchema = z.object({ frames: z .array(structuredFrameSchema) - .describe("Oldest call first; the frame that raised is last."), + .describe( + "Most recent call last. The most relevant frame is the last in-app frame.", + ), framesOmitted: z.number().nullable(), }); @@ -75,51 +90,82 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -type FrameContext = Pick< - StructuredFrame, - "contextLine" | "preContext" | "postContext" ->; +/** Source lines from Sentry's `[lineNo, line]` context between two line numbers. */ +function contextLinesWithin(frame: Frame, from: number, to: number) { + const lines = (frame.context ?? []) + .filter(([contextLineNo]) => contextLineNo >= from && contextLineNo <= to) + .map(([, line]) => line); + return lines.length > 0 ? lines : undefined; +} -/** Splits Sentry's `[lineNo, line]` context around the frame's own line, as the event protocol does. */ -function mapContext(frame: Frame): FrameContext { - const lineNo = frame.lineNo; - const context = lineNo == null ? [] : (frame.context ?? []); - const contextLine = - context.find(([contextLineNo]) => contextLineNo === lineNo)?.[1] ?? null; - if (frame.inApp !== true || lineNo == null) { - return { contextLine, preContext: null, postContext: null }; +/** Copies local variables key by key, cutting values whose JSON is too long. */ +function mapVars( + vars: Record | null | undefined, +): Record | undefined { + if (!vars) { + return undefined; } - const linesWithin = (from: number, to: number) => { - const lines = context - .filter(([contextLineNo]) => contextLineNo >= from && contextLineNo <= to) - .map(([, line]) => line); - return lines.length > 0 ? lines : null; - }; - return { - contextLine, - preContext: linesWithin(lineNo - IN_APP_CONTEXT_LINES, lineNo - 1), - postContext: linesWithin(lineNo + 1, lineNo + IN_APP_CONTEXT_LINES), - }; + const mapped: Record = {}; + for (const [key, value] of Object.entries(vars)) { + const json = JSON.stringify(value) ?? ""; + mapped[key] = + json.length > MAX_VAR_LENGTH + ? `${json.slice(0, MAX_VAR_LENGTH)}…` + : value; + } + return Object.keys(mapped).length > 0 ? mapped : undefined; } -/** Maps one upstream frame field by field; raw addresses, symbols and source links stay out. */ -export function mapStructuredFrame(frame: Frame): StructuredFrame { - const vars = - frame.inApp === true && frame.vars && Object.keys(frame.vars).length > 0 - ? { ...frame.vars } - : null; - return { - filename: nonEmptyString(frame.filename), - absPath: nonEmptyString(frame.absPath), - module: nonEmptyString(frame.module), - package: nonEmptyString(frame.package), - function: nonEmptyString(frame.function), - lineNo: finiteNumber(frame.lineNo), - colNo: finiteNumber(frame.colNo), - inApp: typeof frame.inApp === "boolean" ? frame.inApp : null, - ...mapContext(frame), - vars, - }; +/** + * Maps one upstream frame field by field and writes only the fields that have + * a value. Raw addresses, symbols and source links stay out. Surrounding + * context lines and variables go only on the detail frame. + */ +export function mapStructuredFrame( + frame: Frame, + { detail = false }: { detail?: boolean } = {}, +): StructuredFrame { + const mapped: StructuredFrame = {}; + const filename = nonEmptyString(frame.filename); + const absPath = nonEmptyString(frame.absPath); + const module = nonEmptyString(frame.module); + const pkg = nonEmptyString(frame.package); + const fn = nonEmptyString(frame.function); + const lineNo = finiteNumber(frame.lineNo); + const colNo = finiteNumber(frame.colNo); + if (filename) mapped.filename = filename; + if (absPath && absPath !== filename) mapped.absPath = absPath; + if (module) mapped.module = module; + if (pkg) mapped.package = pkg; + if (fn) mapped.function = fn; + if (lineNo !== null) mapped.lineNo = lineNo; + if (colNo !== null) mapped.colNo = colNo; + if (frame.inApp === true) mapped.inApp = true; + if (lineNo === null) { + return mapped; + } + const contextLine = frame.context?.find( + ([contextLineNo]) => contextLineNo === lineNo, + )?.[1]; + if (contextLine !== undefined) mapped.contextLine = contextLine; + if (!detail) { + return mapped; + } + const preContext = contextLinesWithin( + frame, + lineNo - DETAIL_CONTEXT_LINES, + lineNo - 1, + ); + const postContext = contextLinesWithin( + frame, + lineNo + 1, + lineNo + DETAIL_CONTEXT_LINES, + ); + const vars = mapVars(frame.vars); + if (preContext) mapped.preContext = preContext; + if (postContext) mapped.postContext = postContext; + if (vars) mapped.vars = vars; + return mapped; } /** Sentry records omitted frames as a `[start, end]` index pair. */ @@ -138,14 +184,23 @@ export function countFramesOmitted( return null; } +/** + * Maps a stacktrace. With `detail`, the most relevant frame (the last in-app + * frame, as Sentry picks it) also gets its surrounding context and variables. + */ export function mapStructuredStacktrace( stacktrace: Stacktrace | null | undefined, + { detail = true }: { detail?: boolean } = {}, ): StructuredStacktrace | null { if (!stacktrace?.frames?.length) { return null; } + const { frames } = stacktrace; + const relevantFrame = detail ? findMostRelevantInAppFrame(frames) : undefined; return { - frames: stacktrace.frames.map(mapStructuredFrame), + frames: frames.map((frame) => + mapStructuredFrame(frame, { detail: frame === relevantFrame }), + ), framesOmitted: countFramesOmitted(stacktrace.framesOmitted), }; } From 58bc1e5998427c349994ab506e9ce4a1ea4c796a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:34:58 +0000 Subject: [PATCH 3/6] fix(mcp): Keep vars on line-less frames and read user aliases mapStructuredFrame returned before copying vars when a frame had no line number, so unsymbolicated in-app frames lost their locals on the structured path. Only the surrounding source lines need a line number. mapUser now falls back to the ip and display_name aliases, matching the markdown formatter. --- .../get-issue-details.structured.test.ts | 20 ++++++++++ .../src/tools/support/issue-event-payload.ts | 6 ++- .../support/structured-stacktrace.test.ts | 21 ++++++++++ .../tools/support/structured-stacktrace.ts | 38 ++++++++++--------- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts index 87746a9a3..57aae986f 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts @@ -764,6 +764,26 @@ describe("get_issue_details structuredContent for a realistic error", () => { expect(payload.event.threads.at(-1).name).toBe("worker-20"); }); + it("falls back to the ip and display_name user aliases", async () => { + mockDjangoIssue({ + ...djangoErrorEvent, + user: { + id: "1182", + email: "jane@example.com", + ip: "203.0.113.24", + display_name: "Jane Doe", + }, + }); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = getStructuredContent>(result); + + expect(payload.event.user).toMatchObject({ + ipAddress: "203.0.113.24", + name: "Jane Doe", + }); + }); + it("turns empty strings into null", async () => { mockDjangoIssue({ ...djangoErrorEvent, diff --git a/packages/mcp-core/src/tools/support/issue-event-payload.ts b/packages/mcp-core/src/tools/support/issue-event-payload.ts index 4ca02d8d3..a46183641 100644 --- a/packages/mcp-core/src/tools/support/issue-event-payload.ts +++ b/packages/mcp-core/src/tools/support/issue-event-payload.ts @@ -435,8 +435,10 @@ function mapUser(event: Event): IssueEventPayload["user"] { id: nonEmptyString(user.id), email: nonEmptyString(user.email), username: nonEmptyString(user.username), - ipAddress: nonEmptyString(user.ip_address), - name: nonEmptyString(user.name), + // Sentry's user interface serializes `ip_address` and `name`; some + // payloads carry the `ip` / `display_name` aliases instead. + ipAddress: nonEmptyString(user.ip_address) ?? nonEmptyString(user.ip), + name: nonEmptyString(user.name) ?? nonEmptyString(user.display_name), geo: mappedGeo && Object.values(mappedGeo).some((value) => value !== null) ? mappedGeo diff --git a/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts index 4b6d5518b..26453bf4e 100644 --- a/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts @@ -198,4 +198,25 @@ describe("mapStructuredStacktrace", () => { expect(vars?.label).toBe("export"); expect(vars?.rows).toBe(`${JSON.stringify(rows).slice(0, 200)}…`); }); + + it("keeps vars on a detail frame that has no line number", () => { + const stacktrace = mapStructuredStacktrace({ + frames: [ + sentryFrame({ + function: "handle_request", + module: "app.views", + inApp: true, + vars: { user_id: 42 }, + }), + ], + framesOmitted: null, + }); + + expect(stacktrace?.frames[0]).toEqual({ + function: "handle_request", + module: "app.views", + inApp: true, + vars: { user_id: 42 }, + }); + }); }); diff --git a/packages/mcp-core/src/tools/support/structured-stacktrace.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.ts index 349c2326a..b31e26c40 100644 --- a/packages/mcp-core/src/tools/support/structured-stacktrace.ts +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.ts @@ -141,29 +141,31 @@ export function mapStructuredFrame( if (lineNo !== null) mapped.lineNo = lineNo; if (colNo !== null) mapped.colNo = colNo; if (frame.inApp === true) mapped.inApp = true; - if (lineNo === null) { - return mapped; + if (lineNo !== null) { + const contextLine = frame.context?.find( + ([contextLineNo]) => contextLineNo === lineNo, + )?.[1]; + if (contextLine !== undefined) mapped.contextLine = contextLine; } - const contextLine = frame.context?.find( - ([contextLineNo]) => contextLineNo === lineNo, - )?.[1]; - if (contextLine !== undefined) mapped.contextLine = contextLine; if (!detail) { return mapped; } - const preContext = contextLinesWithin( - frame, - lineNo - DETAIL_CONTEXT_LINES, - lineNo - 1, - ); - const postContext = contextLinesWithin( - frame, - lineNo + 1, - lineNo + DETAIL_CONTEXT_LINES, - ); + // Surrounding source lines need a line number; local variables do not. + if (lineNo !== null) { + const preContext = contextLinesWithin( + frame, + lineNo - DETAIL_CONTEXT_LINES, + lineNo - 1, + ); + const postContext = contextLinesWithin( + frame, + lineNo + 1, + lineNo + DETAIL_CONTEXT_LINES, + ); + if (preContext) mapped.preContext = preContext; + if (postContext) mapped.postContext = postContext; + } const vars = mapVars(frame.vars); - if (preContext) mapped.preContext = preContext; - if (postContext) mapped.postContext = postContext; if (vars) mapped.vars = vars; return mapped; } From c8a3f49f04d329f9bdfa5770bd7c28bfc131e7c5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:03:14 +0000 Subject: [PATCH 4/6] fix(mcp): Fall back to dateReceived for occurredAt The transaction and unknown (CSP and others) event schemas drop dateCreated, so structured get_issue_details reported occurredAt: null for those events. Move getEventTimestamp next to the payload builder and use it for occurredAt, so both paths fall back to dateReceived. --- .../tools/catalog/get-issue-details.test.ts | 78 +++++++++++++++++++ .../src/tools/catalog/get-issue-details.ts | 13 +--- .../src/tools/support/issue-event-payload.ts | 18 +++-- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index 1846c501c..c756e4a0c 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -2264,6 +2264,84 @@ describe("structuredContent", () => { logIssueSpy.mockRestore(); }); + // Sentry's event serializer sends dateCreated and dateReceived on every + // event, but the transaction and unknown (CSP and others) event schemas + // drop dateCreated, so occurredAt comes from dateReceived. + it("takes occurredAt from dateReceived for a transaction event", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/", + () => HttpResponse.json(createPerformanceIssue()), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/7890123456/events/latest/", + () => + HttpResponse.json( + createPerformanceEvent({ + dateReceived: "2025-08-06T18:00:01.250000Z", + }), + ), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/", + () => HttpResponse.json(createTraceResponseFixture()), + ), + ); + + const result = await getIssueDetails.handler( + { ...params, issueId: "PERF-N1-001" }, + experimentalContext, + ); + const payload = getStructuredContent>(result); + + expect(payload.event.type).toBe("transaction"); + expect(payload.event.occurredAt).toBe("2025-08-06T18:00:01.250Z"); + }); + + it("takes occurredAt from dateReceived for a CSP event", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/4256774711/events/latest/", + () => HttpResponse.json(createCspEvent()), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/BLOG-CSP-4XC/", + () => HttpResponse.json(createCspIssue()), + ), + ); + + const result = await getIssueDetails.handler( + { ...params, issueId: "BLOG-CSP-4XC" }, + experimentalContext, + ); + const payload = getStructuredContent>(result); + + expect(payload.event.type).toBe("csp"); + expect(payload.event.occurredAt).toBe("2025-11-19T19:24:12.491Z"); + }); + + it("takes occurredAt from dateReceived for an unknown event type", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/*/issues/7777777777/events/latest/", + () => HttpResponse.json(createUnknownEvent()), + ), + http.get( + "https://sentry.io/api/0/organizations/*/issues/FUTURE-TYPE-001", + () => HttpResponse.json(createUnsupportedIssue()), + ), + ); + + const result = await getIssueDetails.handler( + { ...params, issueId: "FUTURE-TYPE-001" }, + experimentalContext, + ); + const payload = getStructuredContent>(result); + + expect(payload.event.type).toBe("future_ai_agent_trace"); + expect(payload.event.occurredAt).toBe("2025-01-01T01:00:00.000Z"); + }); + it("maps a transaction's N+1 evidence and its spans", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.ts index 8c62f137d..302dedd94 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -62,6 +62,7 @@ import type { ServerContext } from "../../types"; import { resolveCodeLocation } from "../support/code-location"; import { buildIssueEventPayload, + getEventTimestamp, issueEventSchema, } from "../support/issue-event-payload"; import { nonEmptyString } from "../support/structured-stacktrace"; @@ -948,18 +949,6 @@ function buildConversationLookupTimeParams(event: Event): { }; } -function getEventTimestamp(event: Event): Date | null { - const timestamp = - getString((event as { dateCreated?: unknown }).dateCreated) ?? - getString(event.dateReceived); - if (!timestamp) { - return null; - } - - const date = new Date(timestamp); - return Number.isNaN(date.getTime()) ? null : date; -} - function getEventTraceId(event: Event): string | undefined { const traceId = event.contexts?.trace?.trace_id; return getString(traceId); diff --git a/packages/mcp-core/src/tools/support/issue-event-payload.ts b/packages/mcp-core/src/tools/support/issue-event-payload.ts index a46183641..d4dfcfc9f 100644 --- a/packages/mcp-core/src/tools/support/issue-event-payload.ts +++ b/packages/mcp-core/src/tools/support/issue-event-payload.ts @@ -184,13 +184,19 @@ function compactMap( } /** The event types differ in where `dateCreated` sits; normalize it to ISO and tolerate bad values. */ -function eventOccurredAt(event: Event): string | null { - const raw = "dateCreated" in event ? event.dateCreated : null; - if (typeof raw !== "string") { +/** + * When the event happened. The transaction and unknown (CSP and others) + * event schemas drop `dateCreated`, so fall back to `dateReceived`. + */ +export function getEventTimestamp(event: Event): Date | null { + const raw = + nonEmptyString((event as { dateCreated?: unknown }).dateCreated) ?? + nonEmptyString(event.dateReceived); + if (!raw) { return null; } - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; } function mapMessage(event: Event): string | null { @@ -485,7 +491,7 @@ export function buildIssueEventPayload({ return { id: event.id, type: nonEmptyString(event.type), - occurredAt: eventOccurredAt(event), + occurredAt: getEventTimestamp(event)?.toISOString() ?? null, platform: nonEmptyString(event.platform), message: mapMessage(stripped), exceptions, From 8a5951cff9ae0cc3ad222a94c43ec4d789b284ec Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:10:28 -0800 Subject: [PATCH 5/6] feat(mcp): Return structured output from get_event_stacktrace (#2044) `get_event_stacktrace` now declares an `outputSchema` and returns `structuredContent` instead of markdown. It's stacked on #2041 and reuses its shared frame and thread shapes, so the two tools agree. ## What changed - The payload has `organizationSlug`, `issueId`, `eventId`, `platform`, `threadCount`, `selection { status, message }`, `thread`, and `availableThreads`. - `selection.status` is one of `selected`, `not_found`, `ambiguous`, or `no_threads`. `message` is `null` when a thread was selected and says why when one wasn't. - `thread` is the selected thread with its stacktrace. Selection without a `thread` argument works as before: Sentry's default of first crashed thread, then first thread with frames, then first thread. - `availableThreads` lists every thread summary, only when the selector matched none or several. - The frame rules from #2041 apply. Only the thread's most relevant frame (its last in-app frame) gets pre/post context and `vars`, and each var is cut at 200 characters of JSON. Library frames keep only their `contextLine`, even when Sentry sends vars and context for them. - Held locks, registers, the raw stacktrace, instruction/symbol addresses, symbols, and source links stay out. - Deletes the `get_event_stacktrace` `OUTPUT_SCHEMA_EXEMPT` entry and the markdown thread formatters (`formatThreadStacktraceOutput`, `formatAvailableThreadList`). - `tool-responses.md` gets an Event Stacktrace section. ## Tokens (tiktoken gpt-4) | Fixture | Markdown | Pretty JSON | Compact JSON | |---|---|---|---| | 40-frame Java thread | 792 | 1908 (2.4x) | 1168 | Before the shared frame trimming, the pretty JSON was 4240. The outputSchema adds 666 tokens to `tools/list`. ## Checks All run with `env -u SENTRY_AUTH_TOKEN`, package-scoped only: - mocks build - mcp-core `generate-definitions` (result committed) - mcp-core tsc - full mcp-core vitest: 2193 passed, 6 skipped - root `pnpm run lint`: 0 warnings, 0 errors Tests snapshot the payload and parse it against the schema. The snapshot fixture includes a library frame that carries vars and context, which get stripped. A no-leak test uses a native frame with addresses, registers, held locks, and a raw stacktrace. ## Merge notes Whichever of this and #2042 merges second also deletes the markdown helpers that become dead once both land, and regenerates `toolDefinitions.json`. Both PRs touch it and `tool-responses.md`, so expect a conflict there. The helpers, all in `internal/formatting.ts`: - `renderInlineContext`, `renderEnhancedFrame` - `formatThreadTable`, `getThreadDisplayValue`, `getThreadFlags` - `renderContextLines`, `renderVariablesTable`, `formatVariableValue` - the test-only `formatFrameHeader` and `detectLanguage`, plus their tests in `formatting.test.ts` - the imports these leave unused Independent reviewer passed; review fixes applied in 843c01d. via @dcramer. -- [View Session](https://junior-prod.sentry.dev/conversations/agent%3Ab44fd3158dc6862268d4e3f3d26b1906) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3Ab44fd3158dc6862268d4e3f3d26b1906/?project=4510944073809921) --------- Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- docs/contributing/tool-responses.md | 10 + 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 | 214 +++++++++- .../catalog/get-event-stacktrace.test.ts | 396 +++++++++++++++--- .../src/tools/catalog/get-event-stacktrace.ts | 24 +- .../src/tools/support/event-stacktrace.ts | 115 +++-- 9 files changed, 663 insertions(+), 209 deletions(-) diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 86c617596..15c0f5873 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -163,6 +163,16 @@ 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, `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 `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 5b2d0aabe..0ddaab050 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -2154,9 +2154,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..89b2f51e4 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,218 @@ }, "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": { + "description": "Why no thread was selected; null when one was.", + "type": ["string", "null"] + } + }, + "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" + }, + "absPath": { + "description": "Present only when it differs from filename.", + "type": "string" + }, + "module": { + "type": "string" + }, + "package": { + "type": "string" + }, + "function": { + "type": "string" + }, + "lineNo": { + "type": "number" + }, + "colNo": { + "type": "number" + }, + "inApp": { + "description": "Present on application frames.", + "type": "boolean", + "const": true + }, + "contextLine": { + "description": "The source line at lineNo.", + "type": "string" + }, + "preContext": { + "description": "Up to 3 source lines before lineNo, on the most relevant frame only.", + "type": "array", + "items": { + "type": "string" + } + }, + "postContext": { + "description": "Up to 3 source lines after lineNo, on the most relevant frame only.", + "type": "array", + "items": { + "type": "string" + } + }, + "vars": { + "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": {} + } + }, + "additionalProperties": false + }, + "description": "Most recent call last. The most relevant frame is the last in-app frame." + }, + "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..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 @@ -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) {"], @@ -78,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, }, @@ -109,10 +173,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 +187,7 @@ function callHandler( }, getServerContext(), ); + return eventStacktraceOutputSchema.parse(result.structuredContent); } describe("get_event_stacktrace", () => { @@ -132,39 +197,64 @@ 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": null, + "status": "selected", + }, + "thread": { + "crashed": true, + "current": true, + "frameCount": 3, + "id": 259, + "name": "main", + "stacktrace": { + "frames": [ + { + "filename": "Thread.java", + "function": "run", + "lineNo": 833, + "module": "java.lang.Thread", + }, + { + "contextLine": " throw new IllegalStateException();", + "filename": "CheckoutActivity.java", + "function": "submitOrder", + "inApp": true, + "lineNo": 42, + "module": "com.example.CheckoutActivity", + "postContext": [ + " }", + ], + "preContext": [ + " Order order = buildOrder();", + " if (order == null) {", + ], + "vars": { + "cartSize": 3, + "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, + }, + "state": "RUNNABLE", + }, + "threadCount": 3, + } `); }); @@ -173,11 +263,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: null, + }); + 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 +315,18 @@ 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: [ + { + filename: "Worker.java", + module: "com.example.Worker", + function: "run", + lineNo: 42, + }, + ], + framesOmitted: null, + }); }); it("falls back to the first thread with frames when no thread crashed", async () => { @@ -262,9 +371,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 +384,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 +393,187 @@ 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": 3, + "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, + } + `); + }); + + 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 }, + ], + }, + }, + ], + }), + ); - **Issue ID**: THREADS-1 - **Event ID**: event-with-threads + const result = await callHandler({ thread: "worker" }); + + 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]); + }); - No thread found with name "missing". + it("reports events without threads", async () => { + setupThreadMocks( + createDefaultEvent({ id: "event-without-threads", entries: [] }), + ); - ## Available Threads + const result = await callHandler(); - | Thread ID | Name | State | Flags | Frames | - | --- | --- | --- | --- | ---: | - | 11 | worker | WAITING | - | 1 | - | 259 | main | RUNNABLE | crashed, current | 2 | - | 300 | no-stack | SLEEPING | - | 0 | + expect(result).toMatchObject({ + eventId: "event-without-threads", + threadCount: 0, + selection: { status: "no_threads" }, + thread: null, + availableThreads: null, + }); + }); - Pass \`thread\` as a numeric Thread ID or exact thread Name." - `); + 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({ + absPath: "/usr/src/app/crash.cpp", + package: "/usr/lib/libapp.so", + function: "crash_handler", + lineNo: 17, + colNo: 5, + inApp: true, + contextLine: " *ptr = 0;", + }); + 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..1425cd61b 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; @@ -14,17 +17,54 @@ type SelectedThread = | { kind: "selected"; thread: Thread; - reason: string; } | { kind: "not_found" | "ambiguous"; 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() + .nullable() + .describe("Why no thread was selected; null when one was."), + }), + 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 +76,7 @@ export async function fetchAndFormatEventStacktrace({ issueId: string; eventId: string; thread?: string | number; -}): Promise { +}): Promise { const event = await apiService.getEventForIssue({ organizationSlug, issueId, @@ -44,30 +84,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: null }, + thread: mapStructuredThread(selected.thread), + availableThreads: null, + }; } function getThreads(event: Event): Thread[] { @@ -82,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") { @@ -157,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]! }; } From 325c809841e956dedef0db27d04552f0421bcfb5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:30:39 +0000 Subject: [PATCH 6/6] feat(mcp): Keep the commit-closing hint for structured get_issue_details The markdown Response Notes told the model that `Fixes ` in a commit message closes the issue. The structured payload has no notes, so put the hint in the tool description, where every session sees it. --- packages/mcp-core/src/skillDefinitions.json | 6 +++--- packages/mcp-core/src/toolDefinitions.json | 2 +- packages/mcp-core/src/tools/catalog/get-issue-details.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index e703643a1..5ecd429f2 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -104,7 +104,7 @@ }, { "name": "get_issue_details", - "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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", + "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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- To close the issue from a commit, put `Fixes ` in the commit message, or `Fixes ` when shortId is only digits\n", "requiredScopes": ["event:read"] }, { @@ -284,7 +284,7 @@ }, { "name": "get_issue_details", - "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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", + "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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- To close the issue from a commit, put `Fixes ` in the commit message, or `Fixes ` when shortId is only digits\n", "requiredScopes": ["event:read"] }, { @@ -445,7 +445,7 @@ }, { "name": "get_issue_details", - "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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", + "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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- To close the issue from a commit, put `Fixes ` in the commit message, or `Fixes ` when shortId is only digits\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 89b2f51e4..2f0797a1f 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -6829,7 +6829,7 @@ { "name": "get_issue_details", "title": "Get Issue Details", - "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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", + "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- Need installed package versions for an exact event (pass eventId and packageNames)\n- Want the suspect commit's SHA, message, author, and source when available\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\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\n\n\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- To close the issue from a commit, put `Fixes ` in the commit message, or `Fixes ` when shortId is only digits\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.ts index 302dedd94..ca478e4a2 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -458,6 +458,7 @@ export default defineTool({ "- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged", "- When using issueUrl, all other parameters are automatically extracted - don't provide them separately", "- If using issueId (not URL), then organizationSlug is required", + "- To close the issue from a commit, put `Fixes ` in the commit message, or `Fixes ` when shortId is only digits", "", ].join("\n"), inputSchema: {