diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 322afc38c..15c0f5873 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -147,6 +147,32 @@ 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), 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. + +### 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`, @@ -209,10 +235,15 @@ 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. 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`, @@ -280,8 +311,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`, so the PR that adds one -also deletes the entry. 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/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..7829f8a2e 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -698,111 +698,13 @@ 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"); -} - -function parseThreadsEntryData( +export function parseThreadsEntryData( data: unknown, ): z.infer | undefined { const result = ThreadsEntrySchema.safeParse(data); 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. @@ -930,7 +832,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 +845,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 +865,7 @@ interface RawSpan { duration?: number; } -interface N1EvidenceData { +export interface N1EvidenceData { parentSpan?: string; parentSpanIds?: string[]; repeatingSpansCompact?: string[]; @@ -997,7 +899,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 +912,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 +936,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 +971,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 +1045,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 +1118,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 +1150,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 +1160,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 +1177,7 @@ function getTraceSpanDurationMs(span: TraceSpan): number { return deltaSeconds * 1000; } } - return 0; + return null; } function buildOffenderSummaries( @@ -1252,7 +1202,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 +1229,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 +1283,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 +1307,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 +1350,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 +1373,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 +1453,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 +1646,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 +1669,7 @@ type MetricAlertQuery = { eventTypes: string[]; }; -type MetricAlertContext = { +export type MetricAlertContext = { evidenceData: Unverified; query?: MetricAlertQuery; monitorId?: string; @@ -1726,7 +1681,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 +1743,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 +1772,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 +1801,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 +1831,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 +1907,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 +1962,7 @@ function buildMetricAlertResponseNotes({ ] .filter(Boolean) .join(" "); - const searchInstruction = formatToolCallInstruction({ + calls.search = { toolName, arguments: { organizationSlug, @@ -2020,16 +1970,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 +2257,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 +2415,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..006e10afe 100644 --- a/packages/mcp-core/src/server.metadata.test.ts +++ b/packages/mcp-core/src/server.metadata.test.ts @@ -38,12 +38,8 @@ 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: - "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", @@ -57,6 +53,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 +231,28 @@ 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), - ), + 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`]; + } + // 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/server.test.ts b/packages/mcp-core/src/server.test.ts index 5e8019607..0ddaab050 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`. @@ -2128,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/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/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 5871078d2..5ecd429f2 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"] }, { @@ -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"] }, { @@ -279,12 +279,12 @@ }, { "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"] }, { "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"] }, { @@ -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"] }, { @@ -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/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/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 4d81b7f8c..2f0797a1f 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" @@ -6617,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-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/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..57aae986f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.structured.test.ts @@ -0,0 +1,890 @@ +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", + "contextLine": " response = wrapped_callback(request, *callback_args, **callback_kwargs)", + "filename": "django/core/handlers/base.py", + "function": "_get_response", + "lineNo": 197, + "module": "django.core.handlers.base", + }, + { + "absPath": "/app/shop/views.py", + "contextLine": " order = OrderService(cart).place(payload['order_id'])", + "filename": "shop/views.py", + "function": "checkout", + "inApp": true, + "lineNo": 42, + "module": "shop.views", + }, + { + "absPath": "/app/shop/services.py", + "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", + "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", + "contextLine": " return self.cursor.execute(sql, params)", + "filename": "django/db/backends/utils.py", + "function": "_execute", + "lineNo": 105, + "module": "django.db.backends.utils", + }, + ], + "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", + }, + ], + "threadCount": 0, + "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 keeps context on the most relevant frame", 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)", + }); + 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)", + 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("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("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, + 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..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 @@ -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,105 @@ 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 () => { + // 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( "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/", @@ -2256,6 +2352,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 +2366,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 +2440,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 +2902,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 +2941,23 @@ describe("selected event package versions", () => { }, ], }, + "performance": null, + "platform": "python", + "request": null, + "tags": [ + { + "key": "level", + "value": "error", + }, + { + "key": "environment", + "value": "production", + }, + ], + "threadCount": 0, + "threads": null, "type": "default", + "user": null, }, "externalIssues": null, "issue": { @@ -2826,13 +2981,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..ca478e4a2 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,13 @@ import { logError, logIssue } from "../../telem/logging"; import { setTargetTagsAndAttributes } from "../../telem/scope"; 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"; +import type { StructuredToolOutput } from "../types"; // mirrors MAX_DISPLAY_REPLAYS in the markdown output const MAX_RELATED_REPLAYS = 5; @@ -67,93 +79,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 +163,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 +185,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 +216,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", @@ -377,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: { @@ -387,10 +469,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, @@ -869,18 +950,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/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]! }; } 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..d4dfcfc9f --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-event-payload.ts @@ -0,0 +1,516 @@ +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. + */ +/** 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(), + 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() + .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."), + 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. */ +/** + * 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 date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; +} + +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, index) => ({ + 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, + // 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; +} + +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, threadCount: 0, 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 + .slice(0, MAX_THREAD_SUMMARIES) + .map(mapStructuredThreadSummary), + threadCount: threads.length, + 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), + // 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 + : 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, threadCount, 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: getEventTimestamp(event)?.toISOString() ?? null, + platform: nonEmptyString(event.platform), + message: mapMessage(stripped), + exceptions, + threads, + threadCount, + 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.test.ts b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts new file mode 100644 index 000000000..26453bf4e --- /dev/null +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.test.ts @@ -0,0 +1,222 @@ +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)}…`); + }); + + 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 new file mode 100644 index 000000000..b31e26c40 --- /dev/null +++ b/packages/mcp-core/src/tools/support/structured-stacktrace.ts @@ -0,0 +1,228 @@ +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; +type Stacktrace = { + frames: Frame[]; + framesOmitted?: unknown[] | null; +}; + +/** 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. Fields with no value are left out. + */ +export const structuredFrameSchema = z.object({ + 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()) + .optional() + .describe( + "Up to 3 source lines before lineNo, on the most relevant frame only.", + ), + postContext: z + .array(z.string()) + .optional() + .describe( + "Up to 3 source lines after lineNo, on the most relevant frame only.", + ), + vars: z + .record(z.string(), z.unknown()) + .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( + "Most recent call last. The most relevant frame is the last in-app frame.", + ), + 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; +} + +/** 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; +} + +/** 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 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 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) { + const contextLine = frame.context?.find( + ([contextLineNo]) => contextLineNo === lineNo, + )?.[1]; + if (contextLine !== undefined) mapped.contextLine = contextLine; + } + if (!detail) { + return mapped; + } + // 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 (vars) mapped.vars = vars; + return mapped; +} + +/** 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; +} + +/** + * 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: frames.map((frame) => + mapStructuredFrame(frame, { detail: frame === relevantFrame }), + ), + 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