diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7eb00f834..76d036344 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3218,7 +3218,7 @@ export class SentryApiService { opts?: RequestOptions, ): Promise { const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`, + `/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/?llmFormat=markdown`, undefined, opts, ); @@ -3983,7 +3983,7 @@ export class SentryApiService { opts?: RequestOptions, ): Promise { const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/autofix/`, + `/organizations/${organizationSlug}/issues/${issueId}/autofix/?llmFormat=markdown`, undefined, opts, ); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 9c6e54913..daa6e2976 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1011,6 +1011,8 @@ 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().optional(), + // 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({ @@ -1231,6 +1233,8 @@ 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(), }); export const EventAttachmentSchema = z.object({ diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 1e7e268bc..7bf1b952f 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -33,6 +33,7 @@ import { getAutofixArtifactSummaries, getStatusDisplayName, isTerminalStatus, + wrapSeerContent, } from "./tool-helpers/seer"; import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting"; import { @@ -1926,6 +1927,16 @@ function formatSeerSummary(autofixState: AutofixRunState | undefined): string { return ""; } + // Prefer the shared formatter's analysis when the endpoint provides it. + // Seer content is LLM-generated, so wrap it in the untrusted-data boundary. + if (autofixState.formatted?.content) { + const wrapped = wrapSeerContent( + autofixState.formatted.content, + autofixState.autofix.run_id, + ); + return `## Seer Analysis\n\n${wrapped}\n`; + } + const { autofix } = autofixState; const parts: string[] = []; @@ -2128,12 +2139,12 @@ export function formatIssueOutput({ // "default" type represents error events without exception data // "generic" type represents performance regressions and metric-based issues // "csp" type represents Content Security Policy violations - if ( + const isSharedFormatterType = event.type === "error" || event.type === "default" || event.type === "generic" || - event.type === "csp" - ) { + event.type === "csp"; + if (isSharedFormatterType) { const typedEvent = event as | z.infer | z.infer @@ -2147,17 +2158,34 @@ export function formatIssueOutput({ output += `**Message**:\n${event.message}\n`; } output += "\n"; - output += formatEventOutput(event, { - performanceTrace, - replaySummary: { + if (isSharedFormatterType && event.formatted?.content) { + // the shared formatter body doesn't include the replay note — add it here to match formatEventOutput + output += formatIssueReplayOutput({ 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) { diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.ts b/packages/mcp-core/src/internal/tool-helpers/seer.ts index be03315bd..da65ae4dd 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.ts @@ -116,6 +116,20 @@ 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 dbae16652..3c99e606e 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,6 +57,42 @@ 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 6dfbe4488..68c60ba64 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 @@ -11,6 +11,7 @@ import { isTerminalStatus, getHumanInterventionGuidance, getOutputForAutofixRun, + wrapSeerContent, getActiveAutofixTodo, getSeerUnsupportedIssueMessage, isSeerSupportedIssue, @@ -177,7 +178,12 @@ export default defineTool({ if (isTerminalStatus(existingStatus)) { // Return results immediately, no polling needed output += `## Analysis ${getStatusDisplayName(existingStatus)}\n\n`; - output += getOutputForAutofixRun(autofixState.autofix); + output += autofixState.formatted?.content + ? wrapSeerContent( + autofixState.formatted.content, + autofixState.autofix.run_id, + ) + : getOutputForAutofixRun(autofixState.autofix); if (existingStatus !== "completed") { output += `\n**Status**: ${existingStatus}\n`; @@ -210,7 +216,12 @@ export default defineTool({ // Check if completed (terminal state) if (isTerminalStatus(status)) { output += `## Analysis ${getStatusDisplayName(status)}\n\n`; - output += getOutputForAutofixRun(autofixState.autofix); + output += autofixState.formatted?.content + ? wrapSeerContent( + autofixState.formatted.content, + autofixState.autofix.run_id, + ) + : getOutputForAutofixRun(autofixState.autofix); if (status !== "completed") { output += `\n**Status**: ${status}\n`; @@ -279,7 +290,12 @@ export default defineTool({ // Show current progress if (autofixState.autofix) { output += `**Current Status**: ${getStatusDisplayName(autofixState.autofix.status)}\n\n`; - output += getOutputForAutofixRun(autofixState.autofix); + output += autofixState.formatted?.content + ? wrapSeerContent( + autofixState.formatted.content, + autofixState.autofix.run_id, + ) + : 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 aefeb65fe..61a713a18 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 @@ -296,6 +296,189 @@ 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( + `${base}/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/PERF-N1-001/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/CLOUDFLARE-MCP-41/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/CLOUDFLARE-MCP-41/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("surfaces AI conversation IDs found by bounded span lookup", async () => { const traceId = "11112222333344445555666677778888"; const event = createDefaultEvent({