Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4282,8 +4282,7 @@ export class SentryApiService {
opts?: RequestOptions,
): Promise<Event> {
const body = await this.requestJSON(
apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/` +
`?llmFormat=json`,
apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`,
undefined,
opts,
);
Expand Down Expand Up @@ -5248,8 +5247,7 @@ export class SentryApiService {
opts?: RequestOptions,
): Promise<AutofixRunState> {
const body = await this.requestJSON(
apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/` +
`?llmFormat=markdown`,
apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/`,
undefined,
opts,
);
Expand Down
4 changes: 0 additions & 4 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(),
});

/**
Expand Down
196 changes: 104 additions & 92 deletions packages/mcp-core/src/internal/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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_'.
Expand All @@ -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"
);
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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
Expand All @@ -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<typeof ErrorEventSchema>
| z.infer<typeof DefaultEventSchema>
Expand All @@ -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) {
Expand All @@ -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<string>;
directToolNames?: ReadonlySet<string>;
}): 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",
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -2382,7 +2388,7 @@ function formatAIConversationResponseNote({
experimentalMode: boolean;
availableToolNames?: ReadonlySet<string>;
directToolNames?: ReadonlySet<string>;
}): string {
}): string[] {
const instructions = formatAIConversationActionInstructions({
organizationSlug,
aiConversations,
Expand All @@ -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;
Expand Down Expand Up @@ -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",
);
Expand Down
14 changes: 0 additions & 14 deletions packages/mcp-core/src/internal/tool-helpers/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,6 @@ function wrapSeerAnalysisOutput({
return `<seer_analysis ${attrs.join(" ")}>\n${output.trimEnd()}\n</seer_analysis>\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.
Expand Down
Loading
Loading