diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7eb00f83..271068f4 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -77,6 +77,7 @@ import { ReplayListResponseSchema, ReplayIdsByResourceSchema, ReplayRecordingSegmentsSchema, + StacktraceLinkSchema, AIConversationSummaryListSchema, AIConversationSpanListSchema, UserReportListSchema, @@ -129,6 +130,7 @@ import type { ReplayDetails, ReplayList, ReplayRecordingSegments, + StacktraceLink, AIConversationSummary, AIConversationSpanList, UserReportList, @@ -3308,6 +3310,58 @@ export class SentryApiService { ); } + /** + * Resolves a stack frame through the project's configured source code mapping. + * The upstream endpoint verifies the mapped path with the SCM integration. + */ + async getStacktraceLink( + { + organizationSlug, + projectSlug, + file, + platform, + lineNo, + absPath, + module, + package: framePackage, + commitId, + groupId, + sdkName, + signal, + }: { + organizationSlug: string; + projectSlug: string; + file: string; + platform?: string; + lineNo?: number; + absPath?: string; + module?: string; + package?: string; + commitId?: string; + groupId?: string; + sdkName?: string; + signal?: AbortSignal; + }, + opts?: RequestOptions, + ): Promise { + const query = new URLSearchParams({ file }); + if (platform) query.set("platform", platform); + if (lineNo !== undefined) query.set("lineNo", String(lineNo)); + if (absPath) query.set("absPath", absPath); + if (module) query.set("module", module); + if (framePackage) query.set("package", framePackage); + if (commitId) query.set("commitId", commitId); + if (groupId) query.set("groupId", groupId); + if (sdkName) query.set("sdkName", sdkName); + + const body = await this.requestJSON( + `/projects/${organizationSlug}/${projectSlug}/stacktrace-link/?${query.toString()}`, + { signal }, + opts, + ); + return StacktraceLinkSchema.parse(body); + } + /** * Lists events for a specific issue. * Uses the issue-specific endpoint which already filters by issue ID. diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 9c6e5491..c2c989e4 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -805,10 +805,13 @@ export const FrameInterface = z colNo: z.number().nullable(), absPath: z.string().nullable(), module: z.string().nullable(), + package: z.string().nullable(), + platform: z.string().nullable(), + sourceLink: z.string().nullable(), // lineno, source code context: z.array(z.tuple([z.number(), z.string()])), inApp: z.boolean().optional(), - vars: z.record(z.string(), z.unknown()).optional(), + vars: z.record(z.string(), z.unknown()).nullable().optional(), }) .partial(); @@ -821,12 +824,15 @@ export const ExceptionInterface = z type: z.string().nullable(), handled: z.boolean().nullable(), }) - .partial(), + .partial() + .nullable(), type: z.string().nullable(), value: z.string().nullable(), - stacktrace: z.object({ - frames: z.array(FrameInterface), - }), + stacktrace: z + .object({ + frames: z.array(FrameInterface), + }) + .nullable(), }) .partial(); @@ -933,6 +939,7 @@ const EventTagsSchema = z.preprocess((value) => { const BaseEventSchema = z.object({ id: z.string(), + groupID: z.string().nullable().optional(), title: z.string(), message: z.string().nullable(), platform: z.string().nullable().optional(), @@ -988,6 +995,26 @@ const BaseEventSchema = z.object({ // "context" (singular) is the legacy "extra" field for arbitrary user-defined data // This is different from "contexts" (plural) which are structured contexts context: z.record(z.string(), z.unknown()).optional(), + sdk: z + .object({ + name: z.string().nullable().optional(), + }) + .passthrough() + .nullable() + .optional(), + release: z + .object({ + lastCommit: z + .object({ + id: z.string(), + }) + .passthrough() + .nullable() + .optional(), + }) + .passthrough() + .nullable() + .optional(), tags: EventTagsSchema.optional(), user: z .object({ @@ -1122,6 +1149,20 @@ export const EventSchema = z.union([ UnknownEventSchema, ]); +export const StacktraceLinkSchema = z + .object({ + config: z + .object({ + repoName: z.string(), + }) + .passthrough() + .nullable() + .optional(), + sourcePath: z.string().nullable().optional(), + sourceUrl: z.string().nullable().optional(), + }) + .passthrough(); + export const EventsResponseSchema = z.object({ data: z.array(z.unknown()), meta: z diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 324a8896..c30520e1 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -103,6 +103,7 @@ import type { ReplayListResponseSchema, ReplayRecordingEventSchema, ReplayRecordingSegmentsSchema, + StacktraceLinkSchema, TagListSchema, TagSchema, TeamListSchema, @@ -155,6 +156,7 @@ export type Event = | TransactionEvent | GenericEvent | UnknownEvent; +export type StacktraceLink = z.infer; export type EventAttachment = z.infer; export type Tag = z.infer; diff --git a/packages/mcp-core/src/internal/code-location.ts b/packages/mcp-core/src/internal/code-location.ts new file mode 100644 index 00000000..9c7c2105 --- /dev/null +++ b/packages/mcp-core/src/internal/code-location.ts @@ -0,0 +1,32 @@ +import type { z } from "zod"; +import type { FrameInterface } from "../api-client"; + +export type Frame = z.infer; + +export type CodeLocation = { + repository?: string; + path?: string; + line?: number; + url: string; +}; + +/** Formats a verified source location for the issue-details response. */ +export function formatCodeLocation(codeLocation: CodeLocation): string { + const output = ["## Code Location", ""]; + if (codeLocation.repository) { + output.push(`**Repository**: ${codeLocation.repository}`); + } + if (codeLocation.path) { + output.push(`**Path**: ${codeLocation.path}`); + } + if (codeLocation.line !== undefined) { + output.push(`**Line**: ${codeLocation.line}`); + } + output.push(`**Source**: ${codeLocation.url}`, ""); + return `${output.join("\n")}\n`; +} + +/** Selects Sentry's most relevant application frame: the last in-app frame. */ +export function findMostRelevantInAppFrame(frames: Frame[]): Frame | undefined { + return frames.findLast((frame) => frame.inApp === true); +} diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 1e7e268b..a78eb3a2 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -29,18 +29,23 @@ import type { TraceSpan, } from "../api-client/types"; import { logIssue } from "../telem/logging"; +import { + type CodeLocation, + findMostRelevantInAppFrame, + formatCodeLocation, +} from "./code-location"; +import { + type AIConversationReference, + formatAIConversationActionInstructions, +} from "./tool-helpers/ai-conversation-actions"; import { getAutofixArtifactSummaries, getStatusDisplayName, isTerminalStatus, } from "./tool-helpers/seer"; import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting"; -import { - formatAIConversationActionInstructions, - type AIConversationReference, -} from "./tool-helpers/ai-conversation-actions"; -import { formatUserGeoSummary } from "./user-formatting"; import { isPlainObject } from "./type-guards"; +import { formatUserGeoSummary } from "./user-formatting"; /** * Convert Seer fixability score to actionability label. @@ -416,12 +421,12 @@ function formatExceptionInterfaceOutput( // Only show enhanced frame for the first (outermost) exception to avoid overwhelming output if (index === 0) { - const firstInAppFrame = findFirstInAppFrame(frames); + const relevantFrame = findMostRelevantInAppFrame(frames); if ( - firstInAppFrame && - (firstInAppFrame.context?.length || firstInAppFrame.vars) + relevantFrame && + (relevantFrame.context?.length || relevantFrame.vars) ) { - parts.push(renderEnhancedFrame(firstInAppFrame, event)); + parts.push(renderEnhancedFrame(relevantFrame, event)); parts.push(""); parts.push("**Full Stacktrace:**"); parts.push("────────────────"); @@ -590,13 +595,10 @@ function formatThreadsInterfaceOutput( const frames = crashedThread.stacktrace.frames; - // Find and format the first in-app frame with enhanced view - const firstInAppFrame = findFirstInAppFrame(frames); - if ( - firstInAppFrame && - (firstInAppFrame.context?.length || firstInAppFrame.vars) - ) { - parts.push(renderEnhancedFrame(firstInAppFrame, event)); + // Find and format the most relevant in-app frame with enhanced view + const relevantFrame = findMostRelevantInAppFrame(frames); + if (relevantFrame && (relevantFrame.context?.length || relevantFrame.vars)) { + parts.push(renderEnhancedFrame(relevantFrame, event)); parts.push(""); parts.push("**Full Stacktrace:**"); parts.push("────────────────"); @@ -725,12 +727,9 @@ export function formatThreadStacktraceOutput({ parts.push(""); } - const firstInAppFrame = findFirstInAppFrame(frames); - if ( - firstInAppFrame && - (firstInAppFrame.context?.length || firstInAppFrame.vars) - ) { - parts.push(renderEnhancedFrame(firstInAppFrame, event)); + const relevantFrame = findMostRelevantInAppFrame(frames); + if (relevantFrame && (relevantFrame.context?.length || relevantFrame.vars)) { + parts.push(renderEnhancedFrame(relevantFrame, event)); parts.push(""); parts.push("**Full Stacktrace:**"); parts.push("────────────────"); @@ -886,27 +885,6 @@ function renderVariablesTable(vars: Record): string { return lines.join("\n"); } -/** - * Finds the first application frame (in_app) in a stack trace. - * Searches from the bottom of the stack (oldest frame) to find the first - * frame that belongs to the user's application code rather than libraries. - * - * @param frames - Array of stack frames, typically in reverse chronological order - * @returns The first in-app frame found, or undefined if none exist - */ -function findFirstInAppFrame( - frames: z.infer[], -): z.infer | undefined { - // Frames are usually in reverse order (most recent first) - // We want the first in-app frame from the bottom - for (let i = frames.length - 1; i >= 0; i--) { - if (frames[i].inApp === true) { - return frames[i]; - } - } - return undefined; -} - /** * Constants for performance issue formatting */ @@ -1979,6 +1957,7 @@ export function formatIssueOutput({ externalIssues, relatedReplayIds, aiConversations, + codeLocation, experimentalMode, availableToolNames, directToolNames, @@ -1992,6 +1971,7 @@ export function formatIssueOutput({ externalIssues?: ExternalIssueList; relatedReplayIds?: string[]; aiConversations?: AIConversationReference[]; + codeLocation?: CodeLocation; experimentalMode?: boolean; availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; @@ -2066,6 +2046,11 @@ export function formatIssueOutput({ output += `**Project**: ${issue.project.name}\n`; output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`; output += "\n"; + + if (codeLocation) { + output += formatCodeLocation(codeLocation); + } + output += "## Event Details\n\n"; // Check if this is an unsupported event type 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 aefeb65f..4668138a 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 @@ -165,6 +165,34 @@ function createTraceResponseFixture() { describe("get_issue_details", () => { it("serializes with issueId", async () => { + let stacktraceLinkRequested = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/CLOUDFLARE-MCP/stacktrace-link/", + ({ request }) => { + stacktraceLinkRequested = true; + const query = new URL(request.url).searchParams; + expect(query.get("file")).toBe("index.js"); + expect(query.get("lineNo")).toBe("19631"); + expect(query.get("platform")).toBe("javascript"); + expect(query.get("absPath")).toBe("/index.js"); + expect(query.get("module")).toBe("index"); + expect(query.get("groupId")).toBe("6507376925"); + expect(query.get("sdkName")).toBe("sentry.javascript.cloudflare"); + + return HttpResponse.json({ + config: { repoName: "getsentry/sentry-mcp" }, + sourcePath: "packages/mcp-cloudflare/src/index.ts", + sourceUrl: + "https://github.com/getsentry/sentry-mcp/blob/main/packages/mcp-cloudflare/src/index.ts#L19631", + integrations: [], + error: null, + }); + }, + { once: true }, + ), + ); + const result = await getIssueDetails.handler( { organizationSlug: "sentry-mcp-evals", @@ -181,6 +209,7 @@ describe("get_issue_details", () => { userId: "1", }, ); + expect(stacktraceLinkRequested).toBe(true); expect(result).toMatchInlineSnapshot(` "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** @@ -199,6 +228,13 @@ describe("get_issue_details", () => { **Project**: CLOUDFLARE-MCP **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + ## Code Location + + **Repository**: getsentry/sentry-mcp + **Path**: packages/mcp-cloudflare/src/index.ts + **Line**: 19631 + **Source**: https://github.com/getsentry/sentry-mcp/blob/main/packages/mcp-cloudflare/src/index.ts#L19631 + ## Event Details **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 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 1622f1ba..432fb911 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.ts @@ -1,38 +1,40 @@ -import { z } from "zod"; import { setTag } from "@sentry/core"; -import { defineTool } from "../../internal/tool-helpers/define"; -import { apiServiceFromContext } from "../../internal/tool-helpers/api"; -import { - parseIssueParams, - formatIssueOutput, - assertIssueWithinProjectConstraint, -} from "../../internal/tool-helpers/issue"; -import { enhanceNotFoundError } from "../../internal/tool-helpers/enhance-error"; +import { z } from "zod"; import { ApiNotFoundError } from "../../api-client"; import type { SentryApiService } from "../../api-client"; import type { AutofixRunState, - Event, - ErrorEvent, DefaultEvent, - TransactionEvent, - Trace, + ErrorEvent, + Event, ExternalIssueList, Issue, + Trace, + TransactionEvent, } from "../../api-client/types"; import { UserInputError } from "../../errors"; -import type { ServerContext } from "../../types"; -import { logError } from "../../telem/logging"; +import type { CodeLocation } from "../../internal/code-location"; import { - addAIConversationSuggestedActions, type AIConversationReference, + addAIConversationSuggestedActions, } from "../../internal/tool-helpers/ai-conversation-actions"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { enhanceNotFoundError } from "../../internal/tool-helpers/enhance-error"; +import { + assertIssueWithinProjectConstraint, + formatIssueOutput, + parseIssueParams, +} from "../../internal/tool-helpers/issue"; import { - ParamOrganizationSlug, - ParamRegionUrl, ParamIssueShortId, ParamIssueUrl, + ParamOrganizationSlug, + ParamRegionUrl, } from "../../schema"; +import { logError } from "../../telem/logging"; +import type { ServerContext } from "../../types"; +import { resolveCodeLocation } from "../support/code-location"; const MAX_AI_CONVERSATION_MATCHES = 3; const AI_CONVERSATION_LOOKUP_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -131,7 +133,7 @@ export default defineTool({ }); // For this call, we might want to provide context if it fails const [ - { event, performanceTrace, aiConversations }, + { event, performanceTrace, aiConversations, codeLocation }, { autofixState, externalIssues, relatedReplayIds }, ] = await Promise.all([ apiService @@ -153,10 +155,11 @@ export default defineTool({ }) .then(async (event) => ({ event, - ...(await fetchEventTraceEnrichment({ + ...(await fetchEventEnrichment({ apiService, organizationSlug: orgSlug, event, + issue, })), })), fetchIssueEnrichmentData({ @@ -177,6 +180,7 @@ export default defineTool({ externalIssues, relatedReplayIds, aiConversations, + codeLocation, experimentalMode: context.experimentalMode, availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, @@ -233,7 +237,7 @@ export default defineTool({ }); const [ - { event, performanceTrace, aiConversations }, + { event, performanceTrace, aiConversations, codeLocation }, { autofixState, externalIssues, relatedReplayIds }, ] = await Promise.all([ apiService @@ -243,10 +247,11 @@ export default defineTool({ }) .then(async (event) => ({ event, - ...(await fetchEventTraceEnrichment({ + ...(await fetchEventEnrichment({ apiService, organizationSlug: orgSlug, event, + issue, })), })), fetchIssueEnrichmentData({ @@ -267,6 +272,7 @@ export default defineTool({ externalIssues, relatedReplayIds, aiConversations, + codeLocation, experimentalMode: context.experimentalMode, availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, @@ -280,19 +286,22 @@ export default defineTool({ }, }); -async function fetchEventTraceEnrichment({ +async function fetchEventEnrichment({ apiService, organizationSlug, event, + issue, }: { apiService: SentryApiService; organizationSlug: string; event: Event; + issue: Issue; }): Promise<{ performanceTrace: Trace | undefined; aiConversations: AIConversationReference[]; + codeLocation: CodeLocation | undefined; }> { - const [performanceTrace, aiConversations] = await Promise.all([ + const [performanceTrace, aiConversations, codeLocation] = await Promise.all([ maybeFetchPerformanceTrace({ apiService, organizationSlug, @@ -303,9 +312,15 @@ async function fetchEventTraceEnrichment({ organizationSlug, event, }), + resolveCodeLocation({ + apiService, + organizationSlug, + projectSlug: issue.project.slug, + event, + }), ]); - return { performanceTrace, aiConversations }; + return { performanceTrace, aiConversations, codeLocation }; } /** diff --git a/packages/mcp-core/src/tools/support/code-location.test.ts b/packages/mcp-core/src/tools/support/code-location.test.ts new file mode 100644 index 00000000..8048b3b9 --- /dev/null +++ b/packages/mcp-core/src/tools/support/code-location.test.ts @@ -0,0 +1,254 @@ +import { createDefaultEvent } from "@sentry/mcp-server-mocks"; +import { describe, expect, it, vi } from "vitest"; +import { ApiPermissionError, type SentryApiService } from "../../api-client"; +import type { Event } from "../../api-client/types"; +import { resolveCodeLocation } from "./code-location"; + +function mockStacktraceLink() { + return vi.fn(); +} + +function resolve( + event: Event, + getStacktraceLink: ReturnType, +) { + return resolveCodeLocation({ + apiService: { getStacktraceLink }, + organizationSlug: "acme", + projectSlug: "backend", + event, + }); +} + +describe("resolveCodeLocation", () => { + it("resolves the last in-app frame from the root exception", async () => { + const event = createDefaultEvent({ + groupID: "123", + platform: "javascript", + sdk: { name: "sentry.javascript.node" }, + release: { lastCommit: { id: "abc123" } }, + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "OriginalError", + stacktrace: { + frames: [ + { + filename: "src/original.ts", + lineNo: 10, + inApp: true, + }, + ], + }, + }, + { + type: "WrappedError", + mechanism: null, + stacktrace: null, + }, + { + type: "RootError", + stacktrace: { + frames: [ + { + filename: "node_modules/library.js", + lineNo: 20, + inApp: false, + }, + { + filename: "src/root.ts", + absPath: "/workspace/src/root.ts", + module: "src.root", + package: "backend", + lineNo: 42, + inApp: true, + }, + { + filename: "runtime.js", + lineNo: 50, + inApp: false, + }, + ], + }, + }, + ], + }, + }, + ], + }) as Event; + const getStacktraceLink = mockStacktraceLink().mockResolvedValue({ + config: { repoName: "acme/backend" }, + sourcePath: "services/api/src/root.ts", + sourceUrl: + "https://github.com/acme/backend/blob/main/services/api/src/root.ts#L42", + }); + + await expect(resolve(event, getStacktraceLink)).resolves.toEqual({ + repository: "acme/backend", + path: "services/api/src/root.ts", + line: 42, + url: "https://github.com/acme/backend/blob/main/services/api/src/root.ts#L42", + }); + expect(getStacktraceLink).toHaveBeenCalledWith( + expect.objectContaining({ + organizationSlug: "acme", + projectSlug: "backend", + file: "src/root.ts", + absPath: "/workspace/src/root.ts", + module: "src.root", + package: "backend", + lineNo: 42, + platform: "javascript", + groupId: "123", + commitId: "abc123", + sdkName: "sentry.javascript.node", + signal: expect.any(AbortSignal), + }), + ); + }); + + it("uses the crashed thread when the event has no exception stacktrace", async () => { + const event = createDefaultEvent({ + entries: [ + { + type: "threads", + data: { + values: [ + { + id: "worker", + crashed: false, + stacktrace: { + frames: [ + { + filename: "src/worker.ts", + lineNo: 10, + inApp: true, + }, + ], + }, + }, + { + id: "main", + crashed: true, + stacktrace: { + frames: [ + { + filename: "src/main.ts", + lineNo: 84, + inApp: true, + }, + ], + }, + }, + ], + }, + }, + ], + }) as Event; + const getStacktraceLink = mockStacktraceLink().mockResolvedValue({ + config: { repoName: "acme/backend" }, + sourcePath: "src/main.ts", + sourceUrl: "https://github.com/acme/backend/blob/main/src/main.ts#L84", + }); + + await expect(resolve(event, getStacktraceLink)).resolves.toMatchObject({ + path: "src/main.ts", + line: 84, + }); + expect(getStacktraceLink).toHaveBeenCalledWith( + expect.objectContaining({ file: "src/main.ts", lineNo: 84 }), + ); + }); + + it.each([ + "https://github.com/acme/backend/blob/abc123/src/main.ts#L84", + "https://www.github.com/acme/backend/blob/abc123/src/main.ts#L84", + ])( + "uses a trusted embedded GitHub source link without another API call: %s", + async (sourceUrl) => { + const event = createDefaultEvent({ + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "Error", + stacktrace: { + frames: [ + { + filename: "src/main.ts", + lineNo: 84, + inApp: true, + sourceLink: sourceUrl, + }, + ], + }, + }, + ], + }, + }, + ], + }) as Event; + const getStacktraceLink = mockStacktraceLink(); + + await expect(resolve(event, getStacktraceLink)).resolves.toEqual({ + repository: "acme/backend", + path: "src/main.ts", + line: 84, + url: sourceUrl, + }); + expect(getStacktraceLink).not.toHaveBeenCalled(); + }, + ); + + it("omits the location when source verification is unavailable", async () => { + const event = createDefaultEvent() as Event; + const getStacktraceLink = mockStacktraceLink().mockRejectedValue( + new ApiPermissionError("Forbidden"), + ); + + await expect(resolve(event, getStacktraceLink)).resolves.toBeUndefined(); + }); + + it("omits a mapped path when SCM source verification fails", async () => { + const event = createDefaultEvent() as Event; + const getStacktraceLink = mockStacktraceLink().mockResolvedValue({ + config: { repoName: "acme/backend" }, + sourcePath: "src/main.ts", + sourceUrl: null, + }); + + await expect(resolve(event, getStacktraceLink)).resolves.toBeUndefined(); + }); + + it("omits the location when source resolution times out", async () => { + vi.useFakeTimers(); + try { + const event = createDefaultEvent() as Event; + const getStacktraceLink = mockStacktraceLink().mockImplementation( + ({ signal }) => { + if (!signal) { + throw new Error("Expected source resolution to provide a signal"); + } + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + }, + ); + + const result = resolve(event, getStacktraceLink); + await vi.advanceTimersByTimeAsync(3000); + await expect(result).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/mcp-core/src/tools/support/code-location.ts b/packages/mcp-core/src/tools/support/code-location.ts new file mode 100644 index 00000000..c93d1159 --- /dev/null +++ b/packages/mcp-core/src/tools/support/code-location.ts @@ -0,0 +1,194 @@ +import { + ApiClientError, + ErrorEntrySchema, + type SentryApiService, + ThreadsEntrySchema, +} from "../../api-client"; +import type { Event } from "../../api-client/types"; +import { ConfigurationError } from "../../errors"; +import { + type CodeLocation, + type Frame, + findMostRelevantInAppFrame, +} from "../../internal/code-location"; +import { logIssue } from "../../telem/logging"; + +const CODE_LOCATION_TIMEOUT_MS = 3000; + +/** Resolves a verified source location, omitting optional enrichment failures. */ +export async function resolveCodeLocation({ + apiService, + organizationSlug, + projectSlug, + event, +}: { + apiService: Pick; + organizationSlug: string; + projectSlug: string; + event: Event; +}): Promise { + const frame = findRelevantFrame(event); + if (!frame) { + return undefined; + } + + const embeddedSourceUrl = getTrustedGitHubSourceUrl(frame.sourceLink); + if (embeddedSourceUrl) { + return { + repository: getGitHubRepository(embeddedSourceUrl), + path: getString(frame.filename) ?? getString(frame.absPath), + ...(frame.lineNo !== null && frame.lineNo !== undefined + ? { line: frame.lineNo } + : {}), + url: embeddedSourceUrl, + }; + } + + const file = getString(frame.filename) ?? getString(frame.absPath); + if (!file) { + return undefined; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + CODE_LOCATION_TIMEOUT_MS, + ); + + try { + const result = await apiService.getStacktraceLink({ + organizationSlug, + projectSlug, + file, + platform: getString(frame.platform) ?? getString(event.platform), + ...(frame.lineNo !== null && frame.lineNo !== undefined + ? { lineNo: frame.lineNo } + : {}), + absPath: getString(frame.absPath), + module: getString(frame.module), + package: getString(frame.package), + commitId: getString(event.release?.lastCommit?.id), + groupId: getString(event.groupID), + sdkName: getString(event.sdk?.name), + signal: controller.signal, + }); + + const sourceUrl = getHttpUrl(result.sourceUrl); + const repository = getString(result.config?.repoName); + const sourcePath = getString(result.sourcePath); + if (!sourceUrl || !repository || !sourcePath) { + return undefined; + } + + return { + repository, + path: sourcePath, + ...(frame.lineNo !== null && frame.lineNo !== undefined + ? { line: frame.lineNo } + : {}), + url: sourceUrl, + }; + } catch (error) { + if ( + !controller.signal.aborted && + !(error instanceof ApiClientError) && + !(error instanceof ConfigurationError) + ) { + logIssue(error, { + loggerScope: ["tools", "get-issue-details", "code-location"], + contexts: { + request: { + organizationSlug, + projectSlug, + groupId: event.groupID, + }, + }, + }); + } + return undefined; + } finally { + clearTimeout(timeout); + } +} + +function findRelevantFrame(event: Event): Frame | undefined { + for (const entry of event.entries) { + if (entry.type !== "exception") { + continue; + } + + const parsed = ErrorEntrySchema.safeParse(entry.data); + if (!parsed.success) { + continue; + } + + const exceptions = + parsed.data.values ?? (parsed.data.value ? [parsed.data.value] : []); + const rootException = [...exceptions] + .reverse() + .find((exception) => exception?.stacktrace?.frames?.length); + if (rootException?.stacktrace?.frames) { + return findMostRelevantInAppFrame(rootException.stacktrace.frames); + } + } + + for (const entry of event.entries) { + if (entry.type !== "threads") { + continue; + } + + const parsed = ThreadsEntrySchema.safeParse(entry.data); + if (!parsed.success) { + continue; + } + + const crashedThread = parsed.data.values?.find( + (thread) => thread.crashed && thread.stacktrace?.frames?.length, + ); + if (crashedThread?.stacktrace?.frames) { + return findMostRelevantInAppFrame(crashedThread.stacktrace.frames); + } + } + + return undefined; +} + +function getHttpUrl(value: unknown): string | undefined { + const url = getString(value); + if (!url) { + return undefined; + } + + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? url + : undefined; + } catch { + return undefined; + } +} + +function getTrustedGitHubSourceUrl(value: unknown): string | undefined { + const url = getHttpUrl(value); + if (!url) { + return undefined; + } + + const parsed = new URL(url); + return parsed.protocol === "https:" && + (parsed.hostname === "github.com" || parsed.hostname === "www.github.com") + ? url + : undefined; +} + +function getGitHubRepository(sourceUrl: string): string | undefined { + const [owner, repository] = new URL(sourceUrl).pathname + .split("/") + .filter(Boolean); + return owner && repository ? `${owner}/${repository}` : undefined; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index ae6ebef5..64a5c5ae 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -926,6 +926,18 @@ export const restHandlers = buildHandlers([ path: "/api/0/organizations/sentry-mcp-evals/issues/6507376926/events/latest/", fetch: () => HttpResponse.json(eventsFixture), }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/CLOUDFLARE-MCP/stacktrace-link/", + fetch: () => + HttpResponse.json({ + config: null, + sourcePath: null, + sourceUrl: null, + integrations: [], + error: "no_code_mappings_for_project", + }), + }, // Performance issue with N+1 query detection {