diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 783694e7b..4665d876e 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -115,6 +115,16 @@ When changing Sentry API endpoint usage, validate the upstream behavior in should model what Sentry returns, but tool responses should model what users need. +### Issue Details + +`get_issue_details` includes the issue's suspect commit when available, with +its SHA, message, author, and source. Structured responses expose +`suspectCommit`; Markdown responses include a `Suspect Commit` section with +the same data. Without a commit, the structured field is `null` and Markdown +omits the section. Commit lookup is optional: failures do not prevent issue +details from loading, but unexpected server or response-validation failures +are reported to Sentry. + ## Structured Content MCP tools may expose `structuredContent` alongside generated text `content`. diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index d7de41410..31ff7e168 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -52,6 +52,7 @@ import { ClientKeyListSchema, ClientKeySchema, CommitListSchema, + CommittersResponseSchema, DashboardListSchema, DashboardSchema, DeployListSchema, @@ -118,6 +119,7 @@ import type { ClientKey, ClientKeyList, CommitList, + CommitterList, Dashboard, DashboardListItem, DeployList, @@ -2792,6 +2794,31 @@ export class SentryApiService { return CommitListSchema.parse(body); } + /** + * Retrieves the current suspect commit for the event's issue, grouped by committer. + * This reflects the issue's current suspect commit, not its state when the event occurred. + * Unlike release commits, this response actually populates `suspectCommitType`. + */ + async getEventCommitters( + { + organizationSlug, + projectSlug, + eventId, + }: { + organizationSlug: string; + projectSlug: string; + eventId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/committers/`, + undefined, + opts, + ); + return CommittersResponseSchema.parse(body).committers; + } + async listMonitors( { organizationSlug, diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 28d2f55f2..6923da3b3 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -794,6 +794,7 @@ export const CommitSchema = z message: z.string().nullable().optional(), dateCreated: z.string().datetime().nullable().optional(), pullRequest: z.record(z.string(), z.unknown()).nullable().optional(), + // The event committers endpoint populates this; release commits usually return an empty string. suspectCommitType: z.string().optional(), author: ApiActorSchema.nullable().optional(), repository: z @@ -808,6 +809,17 @@ export const CommitSchema = z export const CommitListSchema = z.array(CommitSchema); +export const CommitterSchema = z + .object({ + author: ApiActorSchema.nullable().optional(), + commits: CommitListSchema, + }) + .passthrough(); + +export const CommittersResponseSchema = z.object({ + committers: z.array(CommitterSchema), +}); + export const IssueActivitySchema = z .object({ id: ApiResourceIdSchema, diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 102de4a96..50db90e4a 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -58,6 +58,7 @@ import type { ClientKeySchema, CommitListSchema, CommitSchema, + CommittersResponseSchema, DashboardListItemSchema, DashboardSchema, DashboardWidgetSchema, @@ -200,6 +201,9 @@ export type MetricAlertRuleList = z.infer; export type ReleaseList = z.infer; export type DeployList = z.infer; export type CommitList = z.infer; +export type CommitterList = z.infer< + typeof CommittersResponseSchema +>["committers"]; export type IssueList = z.infer; export type IssueActivityList = z.infer< typeof IssueActivityListResponseSchema diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index cdf94f819..d81f91a15 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -21,6 +21,7 @@ import type { import { ThreadsEntrySchema } from "../api-client"; import type { AutofixRunState, + CommitterList, Event, ExternalIssueList, GenericEvent, @@ -1984,6 +1985,22 @@ function formatSeerSummary(autofixState: AutofixRunState | undefined): string { return `${parts.join("\n")}\n\n`; } +/** Projects the suspect commit consistently for structured and markdown issue details. */ +export function getSuspectCommit(committers: CommitterList | undefined) { + // The endpoint currently returns the issue's latest suspect commit, grouped by author. + const committer = committers?.[0]; + const commit = committer?.commits[0]; + if (!commit) { + return null; + } + return { + id: String(commit.id), + message: commit.message, + author: committer.author?.name ?? committer.author?.email, + suspectCommitType: commit.suspectCommitType, + }; +} + /** * Formats a Sentry issue with its latest event into comprehensive markdown output. * Includes issue metadata, event details, and usage instructions. @@ -2002,6 +2019,7 @@ export function formatIssueOutput({ relatedReplayIds, aiConversations, codeLocation, + committers, experimentalMode, availableToolNames, directToolNames, @@ -2016,6 +2034,7 @@ export function formatIssueOutput({ relatedReplayIds?: string[]; aiConversations?: AIConversationReference[]; codeLocation?: CodeLocation; + committers?: CommitterList; experimentalMode?: boolean; availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; @@ -2091,6 +2110,22 @@ export function formatIssueOutput({ output += formatCodeLocation(codeLocation); } + const suspectCommit = getSuspectCommit(committers); + if (suspectCommit) { + output += "## Suspect Commit\n\n"; + output += `**SHA**: \`${suspectCommit.id}\`\n`; + if (suspectCommit.message) { + output += `**Message**: ${suspectCommit.message}\n`; + } + if (suspectCommit.author) { + output += `**Author**: ${suspectCommit.author}\n`; + } + if (suspectCommit.suspectCommitType) { + output += `**Source**: ${suspectCommit.suspectCommitType}\n`; + } + output += "\n"; + } + output += "## Event Details\n\n"; // Check if this is an unsupported event type diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 179672695..3f28e2b1c 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -94,7 +94,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- 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- 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", "requiredScopes": ["event:read"] }, { @@ -239,7 +239,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- 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- 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", "requiredScopes": ["event:read"] }, { @@ -365,7 +365,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- 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- 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", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 0d206bd22..249866622 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -3753,7 +3753,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- 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- 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", "inputSchema": { "type": "object", "properties": { 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 abbb465dd..407f1e34f 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 @@ -11,10 +11,14 @@ import { issueNullCulpritFixture, mswServer, } from "@sentry/mcp-server-mocks"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it } from "vitest"; +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Skill } from "../../skills"; -import { getTextContent } from "../../test-utils/structured-content"; +import * as logging from "../../telem/logging"; +import { + getStructuredContent, + getTextContent, +} from "../../test-utils/structured-content"; import getIssueDetails, { getIssueDetailsOutputSchema, } from "./get-issue-details.js"; @@ -2409,22 +2413,28 @@ describe("structuredContent", () => { 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(); mswServer.use( http.get( "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/latest/", () => HttpResponse.json({ - ...createDefaultEvent(), + ...event, type: "transaction", formatted: { format: "json", content: FORMATTER_JSON }, }), ), + 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 }), + ), ); const result = await getIssueDetails.handler(params, baseContext); expect(result).not.toHaveProperty("structuredContent"); expect(result).toContain("CLOUDFLARE-MCP-41"); + expect(result).not.toContain("## Suspect Commit"); }); it("keeps the attached replay, which lives on the event not the related list", async () => { @@ -2657,3 +2667,198 @@ describe("structuredContent", () => { expect(payload.replays.related).toHaveLength(5); }); }); + +describe("suspect commits", () => { + const sha = "2ce6a2700fec4913a2cde8e2d41dee362ce6a270"; + const fixtureEventId = "8d17c61b471a4a2ab0c79b32cae564ef"; + const params = { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + eventId: undefined, + issueUrl: undefined, + regionUrl: null, + }; + const formatted = { + format: "json", + content: JSON.stringify({ title: { text: "Example error" } }), + }; + 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", + ) { + mswServer.use( + http.get( + `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/${eventSelector}/`, + () => + HttpResponse.json({ + ...createDefaultEvent({ + id: fixtureEventId, + eventID: fixtureEventId, + }), + ...options, + }), + ), + ); + } + + beforeEach(() => mswServer.resetHandlers()); + afterEach(() => { + mswServer.resetHandlers(); + vi.restoreAllMocks(); + }); + + 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 }) => { + it.each([ + { selection: "latest event", eventId: undefined }, + { + selection: "explicit event ID", + eventId: fixtureEventId, + }, + ])( + "includes the suspect commit for the $selection", + async ({ eventId }) => { + mockEvent({ type, formatted }, eventId); + mswServer.use( + http.get(committersUrl, () => + HttpResponse.json({ + committers: [ + { + author: { name: "Jane Developer", email: "jane@example.com" }, + commits: [ + { + id: sha, + message: "Fix duplicate tool registration", + suspectCommitType: "via SCM integration", + }, + ], + }, + ], + }), + ), + ); + + const result = await getIssueDetails.handler( + { ...params, eventId }, + baseContext, + ); + + if (structured) { + const payload = getIssueDetailsOutputSchema.parse( + getStructuredContent(result), + ); + expect(payload.suspectCommit).toEqual({ + id: sha, + message: "Fix duplicate tool registration", + author: "Jane Developer", + suspectCommitType: "via SCM integration", + }); + } else { + expect(result).toContain(`## Suspect Commit + +**SHA**: \`${sha}\` +**Message**: Fix duplicate tool registration +**Author**: Jane Developer +**Source**: via SCM integration + +## Event Details`); + } + }, + ); + }); + + it("uses the author's email and omits a null commit message from Markdown", async () => { + mockEvent({ formatted: undefined }); + mswServer.use( + http.get(committersUrl, () => + HttpResponse.json({ + committers: [ + { + author: { name: null, email: "dev@example.com" }, + commits: [ + { + id: sha, + message: null, + suspectCommitType: "via commit in release", + }, + ], + }, + ], + }), + ), + ); + + const result = await getIssueDetails.handler(params, baseContext); + + expect(result).toContain(`## Suspect Commit + +**SHA**: \`${sha}\` +**Author**: dev@example.com +**Source**: via commit in release + +## Event Details`); + }); + + it.each([ + { + failure: "permission denied", + status: 403, + body: { detail: "Permission denied" }, + reported: false, + }, + { + failure: "no committers found", + status: 404, + body: { detail: "No committers found" }, + reported: false, + }, + { + failure: "server failure", + status: 500, + body: { detail: "Internal error" }, + reported: true, + }, + { + failure: "invalid response schema", + status: 200, + body: { committers: [{ commits: [{ message: "Missing commit ID" }] }] }, + reported: true, + }, + ])( + "preserves issue details for $failure (reported: $reported)", + async ({ status, body, reported }) => { + mockEvent({ formatted }); + 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 payload = getIssueDetailsOutputSchema.parse( + getStructuredContent(result), + ); + + expect(payload.issue.shortId).toBe("CLOUDFLARE-MCP-41"); + expect(payload.suspectCommit).toBeNull(); + if (reported) { + expect(logIssue).toHaveBeenCalledExactlyOnceWith( + expect.any(Error), + expect.objectContaining({ + loggerScope: ["tools", "get-issue-details", "committers"], + }), + ); + } else { + expect(logIssue).not.toHaveBeenCalled(); + } + }, + ); +}); 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 6d2f910c1..c372b3cf4 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import { setOrganizationContext } from "../../telem/organization"; import type { SentryApiService } from "../../api-client"; -import { ApiNotFoundError } from "../../api-client"; +import { ApiClientError, ApiNotFoundError } from "../../api-client"; import type { AutofixRunState, + CommitterList, DefaultEvent, ErrorEvent, Event, @@ -12,7 +13,7 @@ import type { Trace, TransactionEvent, } from "../../api-client/types"; -import { UserInputError } from "../../errors"; +import { ConfigurationError, UserInputError } from "../../errors"; import type { CodeLocation } from "../../internal/code-location"; import type { AIConversationReference } from "../../internal/tool-helpers/ai-conversation-actions"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; @@ -24,6 +25,7 @@ import { getReplayIdFromEvent, isPerformanceIssueType, getSeerActionabilityLabel, + getSuspectCommit, usesSharedFormatterBody, } from "../../internal/formatting"; import { @@ -42,7 +44,7 @@ import { ParamOrganizationSlug, ParamRegionUrl, } from "../../schema"; -import { logError } from "../../telem/logging"; +import { logError, logIssue } from "../../telem/logging"; import type { ServerContext } from "../../types"; import { resolveCodeLocation } from "../support/code-location"; @@ -106,6 +108,14 @@ export const getIssueDetailsOutputSchema = z.object({ url: z.string(), }) .nullish(), + suspectCommit: z + .object({ + id: z.string(), + message: z.string().nullish(), + author: z.string().nullish(), + suspectCommitType: z.string().nullish(), + }) + .nullish(), replays: z .object({ attached: z.string().nullish(), @@ -212,6 +222,7 @@ function buildIssueDetailsPayload({ relatedReplayIds, aiConversations, codeLocation, + committers, }: { organizationSlug: string; issue: Issue; @@ -223,6 +234,7 @@ function buildIssueDetailsPayload({ relatedReplayIds?: string[]; aiConversations?: AIConversationReference[]; codeLocation?: CodeLocation; + committers?: CommitterList; }): GetIssueDetailsPayload { const autofix = autofixState?.autofix; // the run's own artifacts, not the whole state: an AutofixRunState carries every step and @@ -282,6 +294,7 @@ function buildIssueDetailsPayload({ } : null, replays: buildReplays(event, relatedReplayIds), + suspectCommit: getSuspectCommit(committers), // 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 externalIssues: externalIssues?.length @@ -313,6 +326,7 @@ export default defineTool({ "- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')", "- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'", "- Want details/stacktrace/analysis for a known issue", + "- Want the suspect commit's SHA, message, author, and source when available", "- Provide a Sentry issue URL", "", "DO NOT USE for:", @@ -400,7 +414,7 @@ export default defineTool({ }); // For this call, we might want to provide context if it fails const [ - { event, performanceTrace, aiConversations, codeLocation }, + { event, performanceTrace, aiConversations, codeLocation, committers }, { autofixState, externalIssues, relatedReplayIds }, ] = await Promise.all([ apiService @@ -451,6 +465,7 @@ export default defineTool({ relatedReplayIds, aiConversations, codeLocation, + committers, }), ); } @@ -468,6 +483,7 @@ export default defineTool({ relatedReplayIds, aiConversations, codeLocation, + committers, experimentalMode: context.experimentalMode, availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, @@ -518,7 +534,7 @@ export default defineTool({ }); const [ - { event, performanceTrace, aiConversations, codeLocation }, + { event, performanceTrace, aiConversations, codeLocation, committers }, { autofixState, externalIssues, relatedReplayIds }, ] = await Promise.all([ apiService @@ -557,6 +573,7 @@ export default defineTool({ relatedReplayIds, aiConversations, codeLocation, + committers, }), ); } @@ -574,6 +591,7 @@ export default defineTool({ relatedReplayIds, aiConversations, codeLocation, + committers, experimentalMode: context.experimentalMode, availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, @@ -595,27 +613,69 @@ async function fetchEventEnrichment({ performanceTrace: Trace | undefined; aiConversations: AIConversationReference[]; codeLocation: CodeLocation | undefined; + committers: CommitterList | undefined; }> { - const [performanceTrace, aiConversations, codeLocation] = await Promise.all([ - maybeFetchPerformanceTrace({ - apiService, - organizationSlug, - event, - }), - maybeFindAIConversationsForIssueEvent({ - apiService, - organizationSlug, - event, - }), - resolveCodeLocation({ - apiService, - organizationSlug, - projectSlug: issue.project.slug, - event, - }), - ]); + const [performanceTrace, aiConversations, codeLocation, committers] = + await Promise.all([ + maybeFetchPerformanceTrace({ + apiService, + organizationSlug, + event, + }), + maybeFindAIConversationsForIssueEvent({ + apiService, + organizationSlug, + event, + }), + resolveCodeLocation({ + apiService, + organizationSlug, + projectSlug: issue.project.slug, + event, + }), + maybeFetchCommitters({ + apiService, + organizationSlug, + projectSlug: issue.project.slug, + event, + }), + ]); + + return { performanceTrace, aiConversations, codeLocation, committers }; +} - return { performanceTrace, aiConversations, codeLocation }; +/** Keeps issue details available when optional commit lookup fails, reporting unexpected failures. */ +async function maybeFetchCommitters({ + apiService, + organizationSlug, + projectSlug, + event, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectSlug: string; + event: Event; +}): Promise { + try { + return await apiService.getEventCommitters({ + organizationSlug, + projectSlug, + eventId: event.id, + }); + } catch (error) { + if ( + !(error instanceof ApiClientError) && + !(error instanceof ConfigurationError) + ) { + logIssue(error, { + loggerScope: ["tools", "get-issue-details", "committers"], + contexts: { + request: { organizationSlug, projectSlug, eventId: event.id }, + }, + }); + } + return undefined; + } } /** diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 2c505f596..38e4929d4 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1428,6 +1428,13 @@ export const restHandlers = buildHandlers([ }, }), }, + // The backend returns 404 when no suspect committers are found. + { + method: "get", + path: "/api/0/projects/:org/:project/events/:eventId/committers/", + fetch: () => + HttpResponse.json({ detail: "No committers found" }, { status: 404 }), + }, // External issue links endpoints (default: empty for most issues) { method: "get",