From e68c08d257e90a4549a8029a40d342414839032e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 17 Jul 2026 10:32:18 -0700 Subject: [PATCH 1/2] feat(issues): Add verified code locations to issue details Resolve the most relevant in-app frame through Sentry's existing stacktrace-link endpoint. Include its verified repository, mapped path, line, and source URL in issue output. Keep enrichment optional so missing mappings, SCM verification failures, permissions, and timeouts do not prevent issue details from returning. Fixes #1157 Co-Authored-By: OpenAI Codex --- packages/mcp-core/src/api-client/client.ts | 54 ++++ packages/mcp-core/src/api-client/schema.ts | 51 +++- packages/mcp-core/src/api-client/types.ts | 2 + .../mcp-core/src/internal/code-location.ts | 32 +++ packages/mcp-core/src/internal/formatting.ts | 71 ++--- .../tools/catalog/get-issue-details.test.ts | 36 +++ .../src/tools/catalog/get-issue-details.ts | 65 +++-- .../src/tools/support/code-location.test.ts | 250 ++++++++++++++++++ .../src/tools/support/code-location.ts | 186 +++++++++++++ packages/mcp-server-mocks/src/index.ts | 12 + 10 files changed, 686 insertions(+), 73 deletions(-) create mode 100644 packages/mcp-core/src/internal/code-location.ts create mode 100644 packages/mcp-core/src/tools/support/code-location.test.ts create mode 100644 packages/mcp-core/src/tools/support/code-location.ts 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..6f1a7b04 --- /dev/null +++ b/packages/mcp-core/src/tools/support/code-location.test.ts @@ -0,0 +1,250 @@ +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("uses a trusted embedded GitHub source link without another API call", async () => { + const sourceUrl = + "https://www.github.com/acme/backend/blob/abc123/src/main.ts#L84"; + 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..4bb930e3 --- /dev/null +++ b/packages/mcp-core/src/tools/support/code-location.ts @@ -0,0 +1,186 @@ +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); + return url?.startsWith("https://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 { From f196e860951577faf108afd813275e92d8a70a42 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 17 Jul 2026 10:55:06 -0700 Subject: [PATCH 2/2] fix(issues): Accept standard GitHub source links Recognize both github.com and www.github.com embedded source links while requiring HTTPS and an exact GitHub hostname. Co-Authored-By: OpenAI Codex --- .../src/tools/support/code-location.test.ts | 72 ++++++++++--------- .../src/tools/support/code-location.ts | 10 ++- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/mcp-core/src/tools/support/code-location.test.ts b/packages/mcp-core/src/tools/support/code-location.test.ts index 6f1a7b04..8048b3b9 100644 --- a/packages/mcp-core/src/tools/support/code-location.test.ts +++ b/packages/mcp-core/src/tools/support/code-location.test.ts @@ -163,43 +163,47 @@ describe("resolveCodeLocation", () => { ); }); - it("uses a trusted embedded GitHub source link without another API call", async () => { - const sourceUrl = - "https://www.github.com/acme/backend/blob/abc123/src/main.ts#L84"; - const event = createDefaultEvent({ - entries: [ - { - type: "exception", - data: { - values: [ - { - type: "Error", - stacktrace: { - frames: [ - { - filename: "src/main.ts", - lineNo: 84, - inApp: true, - sourceLink: sourceUrl, - }, - ], + 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(); + ], + }) 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(); - }); + 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; diff --git a/packages/mcp-core/src/tools/support/code-location.ts b/packages/mcp-core/src/tools/support/code-location.ts index 4bb930e3..c93d1159 100644 --- a/packages/mcp-core/src/tools/support/code-location.ts +++ b/packages/mcp-core/src/tools/support/code-location.ts @@ -171,7 +171,15 @@ function getHttpUrl(value: unknown): string | undefined { function getTrustedGitHubSourceUrl(value: unknown): string | undefined { const url = getHttpUrl(value); - return url?.startsWith("https://www.github.com/") ? url : undefined; + 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 {