diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 115a3687c..7e25485f2 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -4282,8 +4282,7 @@ export class SentryApiService { opts?: RequestOptions, ): Promise { const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/` + - `?llmFormat=json`, + apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`, undefined, opts, ); @@ -5248,8 +5247,7 @@ export class SentryApiService { opts?: RequestOptions, ): Promise { const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/` + - `?llmFormat=markdown`, + apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/`, undefined, opts, ); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..c39cdb68a 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1178,8 +1178,6 @@ const BaseEventSchema = z.object({ _meta: z.unknown().optional(), // dateReceived is when the server received the event (may not be present in all contexts) dateReceived: z.string().datetime().nullish(), - // shared-formatter output, present when the event endpoint is called with ?llmFormat - formatted: z.object({ format: z.string(), content: z.string() }).optional(), }); export const ErrorEventSchema = BaseEventSchema.omit({ @@ -1414,8 +1412,6 @@ export const AutofixRunStateSchema = z.object({ }) .passthrough() .nullable(), - // shared-formatter output, present when the autofix endpoint is called with ?llmFormat - formatted: z.object({ format: z.string(), content: z.string() }).optional(), }); /** diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 4f2c12fde..92e0e0dce 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -43,7 +43,6 @@ import { getAutofixArtifactSummaries, getStatusDisplayName, isTerminalStatus, - wrapSeerContent, } from "./tool-helpers/seer"; import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting"; import { isPlainObject } from "./type-guards"; @@ -181,15 +180,6 @@ export function formatFrameHeader( } } -/** - * Whether the shared formatter covers this event type, and so whether its body should be used - * instead of the local rendering. - * - * "default" is an error event without exception data, "generic" a performance regression or - * metric issue, "csp" a Content Security Policy violation. Anything else (a transaction, most - * notably) keeps the local path, which renders things the shared body does not carry such as - * the fetched performance trace. - */ /** * Whether to read the issue's metadata instead of its top level fields. Performance issues can * have various categories such as 'db_query', but the issueType starts with 'performance_'. @@ -208,10 +198,12 @@ export function isPerformanceIssueType(issue: { ); } -export function usesSharedFormatterBody(event: { type?: unknown }): boolean { +/** Whether this server can render the event type; anything else arrives as an UnknownEvent. */ +export function isSupportedEventType(event: { type?: unknown }): boolean { return ( event.type === "error" || event.type === "default" || + event.type === "transaction" || event.type === "generic" || event.type === "csp" ); @@ -1954,24 +1946,17 @@ function formatSeerSummary(autofixState: AutofixRunState | undefined): string { parts.push(""); } - // Prefer the shared formatter's analysis for the body when the endpoint provides it, but - // keep the status handling around it: a run that failed or needs input must say so either - // way. Seer content is LLM-generated, so wrap it in the untrusted-data boundary. - if (autofixState.formatted?.content) { - parts.push(wrapSeerContent(autofixState.formatted.content, autofix.run_id)); - } else { - // Summarize from the run's artifacts: the solution if available, otherwise - // the root cause if it has been identified. - const { rootCause, solution } = getAutofixArtifactSummaries(autofix); - if (solution) { - parts.push("**Summary:**"); - parts.push(solution); - } else if (rootCause) { - parts.push("**Root Cause Identified:**"); - parts.push(rootCause); - } else if (!isTerminalStatus(autofix.status)) { - parts.push("Analysis has started but no results yet."); - } + // Summarize from the run's artifacts: the solution if available, otherwise + // the root cause if it has been identified. + const { rootCause, solution } = getAutofixArtifactSummaries(autofix); + if (solution) { + parts.push("**Summary:**"); + parts.push(solution); + } else if (rootCause) { + parts.push("**Root Cause Identified:**"); + parts.push(rootCause); + } else if (!isTerminalStatus(autofix.status)) { + parts.push("Analysis has started but no results yet."); } if (autofix.status === "error") { @@ -2132,14 +2117,7 @@ export function formatIssueOutput({ // Event type union is: ErrorEvent | DefaultEvent | TransactionEvent | GenericEvent | CspEvent // But in practice we may have other types returned as UnknownEvent const eventType = event.type; - const isUnsupportedType = - eventType !== "error" && - eventType !== "default" && - eventType !== "transaction" && - eventType !== "generic" && - eventType !== "csp"; - - if (isUnsupportedType) { + if (!isSupportedEventType(event)) { // Log to Sentry for tracking new/unknown event types const sentryEventId = logIssue( `Unsupported event type encountered: ${String(eventType)}`, @@ -2170,13 +2148,15 @@ export function formatIssueOutput({ if (aiConversations && aiConversations.length > 0) { output += "\n## Response Notes\n\n"; - output += formatAIConversationResponseNote({ + output += buildAIConversationResponseNotes({ aiConversations, organizationSlug, experimentalMode: experimentalMode ?? false, availableToolNames, directToolNames, - }); + }) + .map((note) => `- ${note}\n`) + .join(""); } // For unsupported event types, return early without trying to render event details @@ -2185,8 +2165,13 @@ export function formatIssueOutput({ output += `**Event ID**: ${event.id}\n`; output += `**Type**: ${event.type}\n`; - const isSharedFormatterType = usesSharedFormatterBody(event); - if (isSharedFormatterType) { + // default: error without exception data, generic: performance/metric issue, csp: CSP violation + if ( + event.type === "error" || + event.type === "default" || + event.type === "generic" || + event.type === "csp" + ) { const typedEvent = event as | z.infer | z.infer @@ -2200,40 +2185,17 @@ export function formatIssueOutput({ output += `**Message**:\n${event.message}\n`; } output += "\n"; - // only a markdown body belongs in this output; a json body is for structuredContent, and - // pasting it here would put a serialized object in the middle of the prose - if ( - isSharedFormatterType && - event.formatted?.format === "markdown" && - event.formatted.content - ) { - // the shared formatter body doesn't include the replay note — add it here to match formatEventOutput - output += formatIssueReplayOutput({ + output += formatEventOutput(event, { + performanceTrace, + replaySummary: { apiService, organizationSlug, - event, relatedReplayIds, experimentalMode: experimentalMode ?? false, availableToolNames, directToolNames, - }); - const formattedContent = event.formatted.content; - output += formattedContent.endsWith("\n") - ? formattedContent - : `${formattedContent}\n`; - } else { - output += formatEventOutput(event, { - performanceTrace, - replaySummary: { - apiService, - organizationSlug, - relatedReplayIds, - experimentalMode: experimentalMode ?? false, - availableToolNames, - directToolNames, - }, - }); - } + }, + }); // Add Seer context if available if (autofixState) { @@ -2249,27 +2211,67 @@ export function formatIssueOutput({ output += "\n"; } + output += "## Response Notes\n\n"; + for (const note of buildIssueResponseNotes({ + organizationSlug, + issue, + event, + apiService, + aiConversations, + experimentalMode, + availableToolNames, + directToolNames, + })) { + output += `- ${note}\n`; + } + return output; +} + +export function buildIssueResponseNotes({ + organizationSlug, + issue, + event, + apiService, + aiConversations, + experimentalMode, + availableToolNames, + directToolNames, +}: { + organizationSlug: string; + issue: Issue; + event: Event; + apiService: SentryApiService; + aiConversations?: AIConversationReference[]; + experimentalMode?: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; +}): string[] { + const notes: string[] = []; const traceId = typeof event.contexts?.trace?.trace_id === "string" && event.contexts.trace.trace_id.length > 0 ? event.contexts.trace.trace_id : undefined; - output += "## Response Notes\n\n"; const commitIssueReference = /^\d+$/.test(issue.shortId) ? apiService.getIssueUrl(organizationSlug, issue.shortId) : issue.shortId; - output += `- Commit message issue reference: \`Fixes ${commitIssueReference}\` automatically closes the issue when the commit is merged.\n`; - output += - "- The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage.\n"; + notes.push( + `Commit message issue reference: \`Fixes ${commitIssueReference}\` automatically closes the issue when the commit is merged.`, + ); + notes.push( + "The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage.", + ); if (aiConversations && aiConversations.length > 0) { - output += formatAIConversationResponseNote({ - aiConversations, - organizationSlug, - experimentalMode: experimentalMode ?? false, - availableToolNames, - directToolNames, - }); + notes.push( + ...buildAIConversationResponseNotes({ + aiConversations, + organizationSlug, + experimentalMode: experimentalMode ?? false, + availableToolNames, + directToolNames, + }), + ); } const issueEventSearchInstruction = formatToolCallInstruction({ toolName: "search_issue_events", @@ -2283,7 +2285,7 @@ export function formatIssueOutput({ directToolNames, fallbackInstruction: "Issue event search is not available in this session", }); - output += `- Issue event search: ${issueEventSearchInstruction}\n`; + notes.push(`Issue event search: ${issueEventSearchInstruction}`); const hasMultipleThreads = event.entries?.some((entry) => { if (entry.type !== "threads") { return false; @@ -2308,7 +2310,7 @@ export function formatIssueOutput({ "to fetch a full thread stacktrace by numeric Thread ID or exact thread Name. Omit `thread` to use Sentry's default selected thread", }); if (stacktraceInstruction) { - output += `- Thread stacktrace lookup: ${stacktraceInstruction}\n`; + notes.push(`Thread stacktrace lookup: ${stacktraceInstruction}`); } } if (traceId) { @@ -2349,9 +2351,11 @@ export function formatIssueOutput({ fallbackInstruction: "Related log search is not available in this session", }); - output += `- Full distributed trace and span tree: ${traceDetailsInstruction}\n`; - output += `- Related span search: ${spanSearchInstruction}\n`; - output += `- Related log search: ${logSearchInstruction}\n`; + notes.push( + `Full distributed trace and span tree: ${traceDetailsInstruction}`, + ); + notes.push(`Related span search: ${spanSearchInstruction}`); + notes.push(`Related log search: ${logSearchInstruction}`); } if (experimentalMode) { const breadcrumbsInstruction = formatToolCallInstruction({ @@ -2365,12 +2369,14 @@ export function formatIssueOutput({ fallbackInstruction: "Issue breadcrumbs are not available in this session", }); - output += `- Breadcrumb trail leading up to this error: ${breadcrumbsInstruction}\n`; + notes.push( + `Breadcrumb trail leading up to this error: ${breadcrumbsInstruction}`, + ); } - return output; + return notes; } -function formatAIConversationResponseNote({ +function buildAIConversationResponseNotes({ aiConversations, organizationSlug, experimentalMode, @@ -2382,7 +2388,7 @@ function formatAIConversationResponseNote({ experimentalMode: boolean; availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; -}): string { +}): string[] { const instructions = formatAIConversationActionInstructions({ organizationSlug, aiConversations, @@ -2396,13 +2402,19 @@ function formatAIConversationResponseNote({ const spanSuffix = conversation.spanId ? ` Matching span: \`${conversation.spanId}\`.` : ""; - return `- Agent conversation found in this trace: \`${conversation.conversationId}\`.${spanSuffix}\n${instructions.map((instruction) => `- ${instruction}`).join("\n")}\n`; + return [ + `Agent conversation found in this trace: \`${conversation.conversationId}\`.${spanSuffix}`, + ...instructions, + ]; } const conversationIds = aiConversations .map((conversation) => `\`${conversation.conversationId}\``) .join(", "); - return `- Multiple agent conversations were found in this trace: ${conversationIds}.\n${instructions.map((instruction) => `- ${instruction}`).join("\n")}\n`; + return [ + `Multiple agent conversations were found in this trace: ${conversationIds}.`, + ...instructions, + ]; } const MAX_DISPLAY_REPLAYS = 5; @@ -2540,7 +2552,7 @@ function normalizeReplayId(replayId: string | null | undefined): string | null { return trimmedReplayId.replace(/-/g, ""); } -function stripReplayMetadata(event: Event): Event { +export function stripReplayMetadata(event: Event): Event { const tags = event.tags?.filter( (tag) => tag.key !== "replay.id" && tag.key !== "replayId", ); diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.ts b/packages/mcp-core/src/internal/tool-helpers/seer.ts index 96293e31d..0a98ca7fd 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.ts @@ -116,20 +116,6 @@ function wrapSeerAnalysisOutput({ return `\n${output.trimEnd()}\n\n`; } -/** - * Wraps shared-formatter Seer analysis content in the provenance boundary, - * mirroring the tags getOutputForAutofixRun applies to MCP-rendered output. - * Seer content is LLM-generated, so it must be marked as untrusted data. - */ -export function wrapSeerContent(content: string, runId?: number): string { - return wrapSeerAnalysisOutput({ - output: content, - runId, - step: "analysis", - includeProvenanceTags: true, - }); -} - // Artifact data shapes from getsentry/sentry's // `src/sentry/seer/autofix/artifact_schemas.py`. Fields are LLM-generated, so // everything is treated as optional. diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts index 3803f0ef3..e5b68895d 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts @@ -57,42 +57,6 @@ describe("analyze_issue_with_seer", () => { expect(result).toContain("The analysis has completed successfully."); }); - it("uses formatted.content from the autofix endpoint when present", async () => { - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-FMT/autofix/", - () => - HttpResponse.json({ - autofix: { run_id: 42, status: "completed", blocks: [] }, - formatted: { - format: "markdown", - content: "## Root Cause\n\nSHARED-AUTOFIX-MARKER", - }, - }), - ), - ); - - const result = await analyzeIssueWithSeer.handler( - { - organizationSlug: "sentry-mcp-evals", - regionUrl: null, - instruction: undefined, - issueId: "CLOUDFLARE-MCP-FMT", - issueUrl: undefined, - }, - { - constraints: { organizationSlug: undefined }, - accessToken: "access-token", - userId: "1", - }, - ); - - expect(result).toContain("SHARED-AUTOFIX-MARKER"); // body from the shared /autofix/ formatter - // LLM-generated content is still wrapped in the untrusted-data boundary - expect(result).toContain(''); - expect(result).toContain(""); - }); - it("wraps completed Seer-authored sections with provenance tags", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts index 20da93674..954576341 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts @@ -19,7 +19,6 @@ import { SEER_MAX_RETRIES, SEER_POLLING_INTERVAL, SEER_TIMEOUT, - wrapSeerContent, } from "../../internal/tool-helpers/seer"; import { ParamIssueShortId, @@ -178,12 +177,7 @@ export default defineTool({ if (isTerminalStatus(existingStatus)) { // Return results immediately, no polling needed output += `## Analysis ${getStatusDisplayName(existingStatus)}\n\n`; - output += autofixState.formatted?.content - ? wrapSeerContent( - autofixState.formatted.content, - autofixState.autofix.run_id, - ) - : getOutputForAutofixRun(autofixState.autofix); + output += getOutputForAutofixRun(autofixState.autofix); if (existingStatus !== "completed") { output += `\n**Status**: ${existingStatus}\n`; @@ -216,12 +210,7 @@ export default defineTool({ // Check if completed (terminal state) if (isTerminalStatus(status)) { output += `## Analysis ${getStatusDisplayName(status)}\n\n`; - output += autofixState.formatted?.content - ? wrapSeerContent( - autofixState.formatted.content, - autofixState.autofix.run_id, - ) - : getOutputForAutofixRun(autofixState.autofix); + output += getOutputForAutofixRun(autofixState.autofix); if (status !== "completed") { output += `\n**Status**: ${status}\n`; @@ -290,12 +279,7 @@ export default defineTool({ // Show current progress if (autofixState.autofix) { output += `**Current Status**: ${getStatusDisplayName(autofixState.autofix.status)}\n\n`; - output += autofixState.formatted?.content - ? wrapSeerContent( - autofixState.formatted.content, - autofixState.autofix.run_id, - ) - : getOutputForAutofixRun(autofixState.autofix); + output += getOutputForAutofixRun(autofixState.autofix); } // Timeout reached 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 7f282caba..84d75e0f4 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 @@ -337,240 +337,6 @@ describe("get_issue_details", () => { expect(result).not.toContain("**Culprit**: null"); }); - it.each([ - { - type: "error/default", - issueId: "CLOUDFLARE-MCP-41", - issue: undefined, - event: createDefaultEvent, - marker: "SHARED-FORMATTER-MARKER", - replacedRenderer: undefined, - }, - { - type: "generic", - issueId: "MCP-SERVER-EQE", - issue: createRegressedIssue, - event: createGenericEvent, - marker: "GENERIC-FORMATTER-MARKER", - replacedRenderer: "### Performance Regression Details", - }, - { - type: "csp", - issueId: "BLOG-CSP-4XC", - issue: createCspIssue, - event: createCspEvent, - marker: "CSP-FORMATTER-MARKER", - replacedRenderer: "### CSP Violation", - }, - ])( - "uses formatted.content for $type events", - async ({ issueId, issue, event, marker, replacedRenderer }) => { - const base = `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/${issueId}`; - if (issue) { - mswServer.use( - http.get(`${base}/`, () => HttpResponse.json(issue()), { - once: true, - }), - ); - } - mswServer.use( - http.get( - `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/${issue ? issue().id : "6507376925"}/events/latest/`, - () => - HttpResponse.json({ - ...event(), - formatted: { - format: "markdown", - content: `## Body\n\n${marker}`, - }, - }), - { once: true }, - ), - ); - - const result = await getIssueDetails.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId, - eventId: undefined, - issueUrl: undefined, - regionUrl: null, - }, - baseContext, - ); - - // the body is rendered from the shared formatter's content - expect(result).toContain(marker); - // ...replacing MCP's type-specific renderer - if (replacedRenderer) { - expect(result).not.toContain(replacedRenderer); - } - }, - ); - - it("ignores formatted.content for non-error events (transaction)", async () => { - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/", - () => HttpResponse.json(createPerformanceIssue()), - { once: true }, - ), - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/7890123456/events/latest/", - () => - HttpResponse.json({ - ...createPerformanceEvent(), - formatted: { - format: "markdown", - content: "TRANSACTION-SHOULD-IGNORE-THIS", - }, - }), - { once: true }, - ), - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/", - () => HttpResponse.json(createTraceResponseFixture()), - { once: true }, - ), - ); - - const result = await getIssueDetails.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "PERF-N1-001", - eventId: undefined, - issueUrl: undefined, - regionUrl: null, - }, - baseContext, - ); - - // transaction events still route through formatEventOutput, so formatted is unused - expect(result).toContain("Issue PERF-N1-001"); // sanity: real output was produced - expect(result).not.toContain("TRANSACTION-SHOULD-IGNORE-THIS"); - }); - - it("keeps the replay note when error events use formatted.content", async () => { - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/latest/", - () => - HttpResponse.json({ - ...createDefaultEvent(), - contexts: { - replay: { - type: "default", - replay_id: "1234567890abcdef1234567890abcdef", - }, - }, - formatted: { - format: "markdown", - content: "## Title\n\nBODY-FROM-FORMATTER", - }, - }), - { once: true }, - ), - ); - - const result = await getIssueDetails.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - eventId: undefined, - issueUrl: undefined, - regionUrl: null, - }, - baseContext, - ); - - expect(result).toContain("BODY-FROM-FORMATTER"); // body from the shared formatter - expect(result).toContain("## Session Replay"); // replay note preserved (was inside formatEventOutput) - }); - - it("embeds the shared formatter's analysis in the Seer section when present", async () => { - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/autofix/", - () => - HttpResponse.json({ - autofix: { run_id: 7, status: "completed", blocks: [] }, - formatted: { - format: "markdown", - content: "## Root Cause\n\nEMBEDDED-SEER-MARKER", - }, - }), - { once: true }, - ), - ); - - const result = await getIssueDetails.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - eventId: undefined, - issueUrl: undefined, - regionUrl: null, - }, - baseContext, - ); - - expect(result).toContain("## Seer Analysis"); - expect(result).toContain("EMBEDDED-SEER-MARKER"); - // LLM-generated content is wrapped in the untrusted-data boundary - expect(result).toContain(''); - }); - - it.each([ - { - label: "in progress", - status: "processing", - expected: "**Status:** Processing", - }, - { - label: "failed", - status: "error", - expected: "**Status:** Analysis failed.", - }, - { - label: "awaiting input", - status: "awaiting_user_input", - expected: "**Status:** Analysis paused - additional information needed.", - }, - ])( - "still reports Seer run status ($label) alongside formatted content", - async ({ status, expected }) => { - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/autofix/", - () => - HttpResponse.json({ - autofix: { run_id: 7, status, blocks: [] }, - formatted: { - format: "markdown", - content: "## Root Cause\n\nEMBEDDED-SEER-MARKER", - }, - }), - { once: true }, - ), - ); - - const result = await getIssueDetails.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - eventId: undefined, - issueUrl: undefined, - regionUrl: null, - }, - baseContext, - ); - - // the formatted body must not hide that the run needs attention - expect(result).toContain("EMBEDDED-SEER-MARKER"); - expect(result).toContain(expected); - }, - ); - it("surfaces agent conversation IDs found by bounded span lookup", async () => { const traceId = "11112222333344445555666677778888"; const event = createDefaultEvent({ @@ -2369,17 +2135,13 @@ describe("get_issue_details", () => { }); describe("structuredContent", () => { - const FORMATTER_JSON = JSON.stringify({ - title: { text: "Error: Tried to cancel a non-cancellable request" }, - exception: { handled: "No", code: "at Object.fetch (index.js:1)" }, - tags: { environment: "production" }, - }); + const experimentalContext = { ...baseContext, experimentalMode: true }; - function mockLatestEventWithFormatted(formatted: unknown) { + function mockLatestEvent(overrides: Record = {}) { mswServer.use( http.get( "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/latest/", - () => HttpResponse.json({ ...createDefaultEvent(), formatted }), + () => HttpResponse.json({ ...createDefaultEvent(), ...overrides }), ), ); } @@ -2392,10 +2154,10 @@ describe("structuredContent", () => { regionUrl: null, }; - it("returns a structured payload when the formatter sends json", async () => { - mockLatestEventWithFormatted({ format: "json", content: FORMATTER_JSON }); + it("returns a structured payload in experimental mode", async () => { + mockLatestEvent(); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); expect(result).toHaveProperty("structuredContent"); const payload = (result as { structuredContent: Record }) @@ -2406,16 +2168,12 @@ describe("structuredContent", () => { expect(payload.issue.url).toContain("CLOUDFLARE-MCP-41"); expect(typeof payload.issue.occurrences).toBe("number"); expect(typeof payload.issue.usersImpacted).toBe("number"); - - // the event body is the formatter's json, embedded as an object rather than a string - expect(payload.event.body).toEqual(JSON.parse(FORMATTER_JSON)); - expect(typeof payload.event.body).toBe("object"); }); it("produces a payload that satisfies the schema", async () => { - mockLatestEventWithFormatted({ format: "json", content: FORMATTER_JSON }); + mockLatestEvent(); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: unknown }) .structuredContent; @@ -2423,50 +2181,93 @@ describe("structuredContent", () => { expect(() => getIssueDetailsOutputSchema.parse(payload)).not.toThrow(); }); - it("falls back to markdown when the org is not on the rollout", async () => { - mockLatestEventWithFormatted(undefined); + it("returns markdown outside experimental mode", async () => { + mockLatestEvent(); const result = await getIssueDetails.handler(params, baseContext); - // a structured result has to carry the whole answer; without the body it would not expect(result).not.toHaveProperty("structuredContent"); expect(result).toContain("CLOUDFLARE-MCP-41"); }); - it("falls back to markdown when the body is not parseable json", async () => { - mockLatestEventWithFormatted({ format: "json", content: "## not json" }); + it("renders the event body itself rather than asking the api for one", async () => { + mockLatestEvent(); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = (result as { structuredContent: Record }) + .structuredContent; - expect(result).not.toHaveProperty("structuredContent"); - expect(result).toContain("CLOUDFLARE-MCP-41"); + 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"); }); - it("keeps transactions on the local path so the performance trace survives", async () => { - // the shared body carries no performance trace; that is fetched separately and only - // rendered for transactions, so a transaction must not take the structured path - const event = createDefaultEvent(); + it("carries the response notes, which say which tool to call next", 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"); + }); + + it("carries the top level message, which the body does not render", async () => { + mockLatestEvent({ message: "TOP-LEVEL-MESSAGE", entries: [] }); + + const result = await getIssueDetails.handler(params, experimentalContext); + const payload = (result as { structuredContent: Record }) + .structuredContent; + + expect(payload.event.message).toBe("TOP-LEVEL-MESSAGE"); + }); + + it("keeps an unsupported event type on the markdown path", async () => { mswServer.use( http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/latest/", - () => - HttpResponse.json({ - ...event, - type: "transaction", - formatted: { format: "json", content: FORMATTER_JSON }, - }), + "https://sentry.io/api/0/organizations/*/issues/7777777777/events/latest/", + () => HttpResponse.json(createUnknownEvent()), ), http.get( - `https://sentry.io/api/0/projects/sentry-mcp-evals/CLOUDFLARE-MCP/events/${event.id}/committers/`, - () => HttpResponse.json({ detail: "Issue not found" }, { status: 404 }), + "https://sentry.io/api/0/organizations/*/issues/FUTURE-TYPE-001", + () => HttpResponse.json(createUnsupportedIssue()), ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler( + { ...params, issueId: "FUTURE-TYPE-001" }, + experimentalContext, + ); expect(result).not.toHaveProperty("structuredContent"); - expect(result).toContain("CLOUDFLARE-MCP-41"); - expect(result).not.toContain("## Suspect Commit"); + expect(result).toContain('Unsupported event type "future_ai_agent_trace"'); + }); + + it("renders a transaction, whose body carries the performance trace", 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()), + ), + ); + + const result = await getIssueDetails.handler( + { ...params, issueId: "PERF-N1-001" }, + experimentalContext, + ); + const payload = (result as { structuredContent: Record }) + .structuredContent; + + expect(payload.event.type).toBe("transaction"); + expect(payload.event.body).toContain("Span"); }); it("keeps the attached replay, which lives on the event not the related list", async () => { @@ -2483,12 +2284,11 @@ describe("structuredContent", () => { replay_id: "1234567890abcdef1234567890abcdef", }, }, - formatted: { format: "json", content: FORMATTER_JSON }, }), ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2497,12 +2297,15 @@ describe("structuredContent", () => { expect(payload.replays?.related).not.toContain( "1234567890abcdef1234567890abcdef", ); + expect(payload.event.body).not.toContain( + "1234567890abcdef1234567890abcdef", + ); }); it("reports no replays when there are none", async () => { - mockLatestEventWithFormatted({ format: "json", content: FORMATTER_JSON }); + mockLatestEvent(); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2518,7 +2321,6 @@ describe("structuredContent", () => { () => HttpResponse.json({ ...createDefaultEvent(), - formatted: { format: "json", content: FORMATTER_JSON }, }), ), http.get( @@ -2537,7 +2339,7 @@ describe("structuredContent", () => { ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2555,12 +2357,11 @@ describe("structuredContent", () => { HttpResponse.json({ ...createDefaultEvent(), dateCreated: "2026-09-03T12:00:00.000Z", - formatted: { format: "json", content: FORMATTER_JSON }, }), ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2612,12 +2413,11 @@ describe("structuredContent", () => { () => HttpResponse.json({ ...createDefaultEvent(), - formatted: { format: "json", content: FORMATTER_JSON }, }), ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2650,12 +2450,11 @@ describe("structuredContent", () => { () => HttpResponse.json({ ...createDefaultEvent(), - formatted: { format: "json", content: FORMATTER_JSON }, }), ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2675,7 +2474,6 @@ describe("structuredContent", () => { () => HttpResponse.json({ ...createDefaultEvent(), - formatted: { format: "json", content: FORMATTER_JSON }, }), ), // related ids come from replay-count, keyed by numeric issue id. Echo back whichever @@ -2690,7 +2488,7 @@ describe("structuredContent", () => { ), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = (result as { structuredContent: Record }) .structuredContent; @@ -2710,16 +2508,10 @@ describe("suspect commits", () => { issueUrl: undefined, regionUrl: null, }; - const formatted = { - format: "json", - content: JSON.stringify({ title: { text: "Example error" } }), - }; + const experimentalContext = { ...baseContext, experimentalMode: true }; const committersUrl = `https://sentry.io/api/0/projects/sentry-mcp-evals/CLOUDFLARE-MCP/events/${fixtureEventId}/committers/`; - function mockEvent( - options: { type?: string; formatted?: unknown } = {}, - eventSelector = "latest", - ) { + function mockEvent(eventSelector = "latest") { mswServer.use( http.get( `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/${eventSelector}/`, @@ -2729,7 +2521,6 @@ describe("suspect commits", () => { id: fixtureEventId, eventID: fixtureEventId, }), - ...options, }), ), ); @@ -2742,14 +2533,9 @@ describe("suspect commits", () => { }); describe.each([ - { mode: "structured JSON", type: "error", formatted, structured: true }, - { - mode: "Markdown without the formatter rollout", - type: "error", - formatted: undefined, - structured: false, - }, - ])("$mode", ({ type, formatted, structured }) => { + { mode: "structured JSON", context: experimentalContext, structured: true }, + { mode: "Markdown", context: baseContext, structured: false }, + ])("$mode", ({ context, structured }) => { it.each([ { selection: "latest event", eventId: undefined }, { @@ -2759,7 +2545,7 @@ describe("suspect commits", () => { ])( "includes the suspect commit for the $selection", async ({ eventId }) => { - mockEvent({ type, formatted }, eventId); + mockEvent(eventId); mswServer.use( http.get(committersUrl, () => HttpResponse.json({ @@ -2781,7 +2567,7 @@ describe("suspect commits", () => { const result = await getIssueDetails.handler( { ...params, eventId }, - baseContext, + context, ); if (structured) { @@ -2809,7 +2595,7 @@ describe("suspect commits", () => { }); it("uses the author's email and omits a null commit message from Markdown", async () => { - mockEvent({ formatted: undefined }); + mockEvent(); mswServer.use( http.get(committersUrl, () => HttpResponse.json({ @@ -2868,13 +2654,13 @@ describe("suspect commits", () => { ])( "preserves issue details for $failure (reported: $reported)", async ({ status, body, reported }) => { - mockEvent({ formatted }); + mockEvent(); const logIssue = vi.spyOn(logging, "logIssue").mockReturnValue(undefined); mswServer.use( http.get(committersUrl, () => HttpResponse.json(body, { status })), ); - const result = await getIssueDetails.handler(params, baseContext); + const result = await getIssueDetails.handler(params, experimentalContext); const payload = getIssueDetailsOutputSchema.parse( getStructuredContent(result), ); 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 76674722a..7c0170065 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -15,12 +15,15 @@ import type { import { ConfigurationError, UserInputError } from "../../errors"; import type { CodeLocation } from "../../internal/code-location"; import { + buildIssueResponseNotes, dedupeReplayIds, + formatEventOutput, getReplayIdFromEvent, getSeerActionabilityLabel, getSuspectCommit, isPerformanceIssueType, - usesSharedFormatterBody, + isSupportedEventType, + stripReplayMetadata, } from "../../internal/formatting"; import type { AIConversationReference } from "../../internal/tool-helpers/ai-conversation-actions"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; @@ -60,10 +63,7 @@ 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 the one open record: it is whatever Sentry's shared formatter emits - * for `?llmFormat=json`, and its sections are decided there. Enumerating them here would make - * this schema a second declaration of that contract, needing a bump every time a section is - * added on the Sentry side. + * `event.body` is this server's own markdown rendering of the event. */ export const getIssueDetailsOutputSchema = z.object({ issue: z.object({ @@ -91,7 +91,8 @@ export const getIssueDetailsOutputSchema = z.object({ id: z.string(), type: z.string().nullish(), occurredAt: z.string().nullish(), - body: z.record(z.string(), z.unknown()), + message: z.string().nullish(), + body: z.string(), }), seer: z .object({ @@ -143,45 +144,21 @@ export const getIssueDetailsOutputSchema = z.object({ }), ) .nullish(), + responseNotes: z.array(z.string()), }); export type GetIssueDetailsPayload = z.infer< typeof getIssueDetailsOutputSchema >; -/** - * Parses the shared formatter's json body. Returns undefined when the caller's org is not on - * the rollout yet, which is the signal to keep returning markdown: a structured result has to - * carry the whole answer, and without the body it would not. - */ -function parseFormattedBody(event: Event): Record | undefined { - // the same event-type gate the markdown path applies: a transaction still needs the local - // rendering, which carries the fetched performance trace that the shared body does not - if (!usesSharedFormatterBody(event)) { - return undefined; - } - const content = event.formatted?.content; - if (!content) { - return undefined; - } - try { - const parsed = JSON.parse(content); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } -} - /** * The attached replay plus the related ones, derived the way the markdown output derives them. * 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 shared-formatter 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. + * ``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; @@ -211,30 +188,45 @@ function buildReplays( }; } +type IssueDetailsArgs = Parameters[0]; + +function issueDetailsResult(args: IssueDetailsArgs) { + if (args.experimentalMode && isSupportedEventType(args.event)) { + return structuredResult(buildIssueDetailsPayload(args)); + } + return formatIssueOutput(args); +} + function buildIssueDetailsPayload({ organizationSlug, issue, event, - body, apiService, autofixState, + performanceTrace, externalIssues, relatedReplayIds, aiConversations, codeLocation, committers, + experimentalMode, + availableToolNames, + directToolNames, }: { organizationSlug: string; issue: Issue; event: Event; - body: Record; apiService: SentryApiService; autofixState?: AutofixRunState; + performanceTrace?: Trace; externalIssues?: ExternalIssueList; relatedReplayIds?: string[]; aiConversations?: AIConversationReference[]; codeLocation?: CodeLocation; committers?: CommitterList; + experimentalMode?: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; }): GetIssueDetailsPayload { const autofix = autofixState?.autofix; // the run's own artifacts, not the whole state: an AutofixRunState carries every step and @@ -276,7 +268,12 @@ function buildIssueDetailsPayload({ id: event.id, type: typeof event.type === "string" ? event.type : null, occurredAt: eventOccurredAt(event), - body, + message: + typeof event.message === "string" && event.message.length > 0 + ? event.message + : null, + // replays are their own field below + body: formatEventOutput(stripReplayMetadata(event), { performanceTrace }), }, seer: autofix ? { @@ -312,6 +309,16 @@ function buildIssueDetailsPayload({ spanId: conversation.spanId, })) : null, + responseNotes: buildIssueResponseNotes({ + organizationSlug, + issue, + event, + apiService, + aiConversations, + experimentalMode, + availableToolNames, + directToolNames, + }), }; } @@ -368,9 +375,9 @@ export default defineTool({ issueUrl: ParamIssueUrl.optional(), }, // outputSchema is deliberately not declared yet. tools/list would export it immediately, - // while an org that is not on sentry's formatter rollout still gets a markdown result with + // 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 the rollout guarantees a json body on every event. + // it up once every success path returns structuredContent. annotations: { readOnlyHint: true, destructiveHint: false, @@ -451,28 +458,7 @@ export default defineTool({ }), ]); - const body = parseFormattedBody(event); - if (body) { - return structuredResult( - buildIssueDetailsPayload({ - organizationSlug: orgSlug, - issue, - event, - body, - apiService, - autofixState, - externalIssues, - relatedReplayIds, - aiConversations, - codeLocation, - committers, - }), - ); - } - - // no shared-formatter body for this org yet: keep returning markdown rather than a - // structured result that is missing the event itself - return formatIssueOutput({ + return issueDetailsResult({ organizationSlug: orgSlug, issue, event, @@ -559,28 +545,7 @@ export default defineTool({ }), ]); - const body = parseFormattedBody(event); - if (body) { - return structuredResult( - buildIssueDetailsPayload({ - organizationSlug: orgSlug, - issue, - event, - body, - apiService, - autofixState, - externalIssues, - relatedReplayIds, - aiConversations, - codeLocation, - committers, - }), - ); - } - - // no shared-formatter body for this org yet: keep returning markdown rather than a - // structured result that is missing the event itself - return formatIssueOutput({ + return issueDetailsResult({ organizationSlug: orgSlug, issue, event,