Skip to content

Commit dcce393

Browse files
dcramercodex
andauthored
feat(mcp): Expand profile and replay event support (#905)
Expand profile, replay, and metrics-adjacent MCP coverage This adds concrete profile detail support for transaction profiles and continuous profiles, routes profile URLs through `get_sentry_resource`, and exposes profiles through the existing `list_events` and `search_events` surfaces so profile queries have both discovery and detail paths. It also aligns replay detail output with upstream replay schemas, makes metrics output point to trace drill-downs instead of inventing a separate detail surface, and fixes follow-up issues in profile formatting and explorer-link generation so profile search results round-trip into Sentry correctly. I kept this within the existing public tool surface rather than adding more top-level tools because the MCP server is already above the preferred tool-count budget. The regression coverage focuses on the changed behavior, including hotspot counting, user-code filtering, profile detail link generation, and grouped profile explorer URLs. --------- Co-authored-by: Codex GPT-5 <noreply@openai.com>
1 parent e5d8f2a commit dcce393

52 files changed

Lines changed: 3093 additions & 282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/mcp-core/src/api-client/client.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,5 +1332,34 @@ describe("API query builders", () => {
13321332
]);
13331333
});
13341334
});
1335+
1336+
describe("profiles page URLs", () => {
1337+
it("should preserve grouped profile field selection", () => {
1338+
const apiService = new SentryApiService({ host: "sentry.io" });
1339+
1340+
const url = apiService.getEventsExplorerUrl(
1341+
"my-org",
1342+
"",
1343+
"123456",
1344+
"profiles",
1345+
["release", "count()"],
1346+
"-count()",
1347+
["count()"],
1348+
["release"],
1349+
"7d",
1350+
);
1351+
1352+
const parsedUrl = new URL(url);
1353+
1354+
expect(parsedUrl.pathname).toBe("/explore/profiling/");
1355+
expect(parsedUrl.searchParams.get("project")).toBe("123456");
1356+
expect(parsedUrl.searchParams.get("statsPeriod")).toBe("7d");
1357+
expect(parsedUrl.searchParams.get("sort")).toBe("-count()");
1358+
expect(parsedUrl.searchParams.getAll("field")).toEqual([
1359+
"release",
1360+
"count()",
1361+
]);
1362+
});
1363+
});
13351364
});
13361365
});

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { z } from "zod";
22
import {
3+
getContinuousProfileUrl as getContinuousProfileUrlUtil,
34
getIssueUrl as getIssueUrlUtil,
5+
getProfileUrl as getProfileUrlUtil,
6+
getProfilingExplorerUrl,
47
getReplayUrl as getReplayUrlUtil,
58
getReplaysSearchUrl as getReplaysSearchUrlUtil,
69
getTraceMetricsExploreUrl,
@@ -10,6 +13,7 @@ import {
1013
} from "../utils/url-utils";
1114
import {
1215
isMetricsDataset,
16+
isProfilesDataset,
1317
normalizeEventsDataset,
1418
type EventsDataset,
1519
} from "../utils/events-datasets";
@@ -42,6 +46,7 @@ import {
4246
UserRegionsSchema,
4347
FlamegraphSchema,
4448
ProfileChunkResponseSchema,
49+
TransactionProfileSchema,
4550
ReplayDetailsSchema,
4651
ReplayListResponseSchema,
4752
ReplayIdsByResourceSchema,
@@ -74,6 +79,7 @@ import type {
7479
User,
7580
Flamegraph,
7681
ProfileChunk,
82+
TransactionProfile,
7783
ReplayDetails,
7884
ReplayList,
7985
ReplayRecordingSegments,
@@ -535,6 +541,36 @@ export class SentryApiService {
535541
return getReplayUrlUtil(this.host, organizationSlug, replayId);
536542
}
537543

544+
getProfileUrl(
545+
organizationSlug: string,
546+
projectSlug: string,
547+
profileId: string,
548+
): string {
549+
return getProfileUrlUtil(
550+
this.host,
551+
organizationSlug,
552+
projectSlug,
553+
profileId,
554+
);
555+
}
556+
557+
getContinuousProfileUrl(
558+
organizationSlug: string,
559+
projectSlug: string,
560+
options: {
561+
profilerId: string;
562+
start: string;
563+
end: string;
564+
},
565+
): string {
566+
return getContinuousProfileUrlUtil(
567+
this.host,
568+
organizationSlug,
569+
projectSlug,
570+
options,
571+
);
572+
}
573+
538574
getReplaysSearchUrl(
539575
organizationSlug: string,
540576
options: Parameters<typeof getReplaysSearchUrlUtil>[2] = {},
@@ -867,6 +903,20 @@ export class SentryApiService {
867903
});
868904
}
869905

906+
if (isProfilesDataset(dataset)) {
907+
return getProfilingExplorerUrl(this.host, organizationSlug, {
908+
query,
909+
projectId,
910+
fields,
911+
sort,
912+
statsPeriod,
913+
start,
914+
end,
915+
aggregateFunctions,
916+
groupByFields,
917+
});
918+
}
919+
870920
// Route to modern EAP API (spans and logs)
871921
return this.buildEapUrl({
872922
organizationSlug,
@@ -2273,7 +2323,7 @@ export class SentryApiService {
22732323
fields: string[];
22742324
limit: number;
22752325
projectId?: string;
2276-
dataset?: "errors" | "tracemetrics";
2326+
dataset?: "errors" | "tracemetrics" | "profiles";
22772327
statsPeriod?: string;
22782328
start?: string;
22792329
end?: string;
@@ -2435,7 +2485,8 @@ export class SentryApiService {
24352485

24362486
if (
24372487
normalizedDataset === "errors" ||
2438-
normalizedDataset === "tracemetrics"
2488+
normalizedDataset === "tracemetrics" ||
2489+
normalizedDataset === "profiles"
24392490
) {
24402491
// Use Discover API query builder
24412492
queryParams = this.buildDiscoverApiQuery({
@@ -2676,6 +2727,23 @@ export class SentryApiService {
26762727
return FlamegraphSchema.parse(body);
26772728
}
26782729

2730+
async getTransactionProfile(
2731+
{
2732+
organizationSlug,
2733+
projectSlugOrId,
2734+
profileId,
2735+
}: {
2736+
organizationSlug: string;
2737+
projectSlugOrId: string | number;
2738+
profileId: string;
2739+
},
2740+
opts?: RequestOptions,
2741+
): Promise<TransactionProfile> {
2742+
const path = `/projects/${organizationSlug}/${projectSlugOrId}/profiling/profiles/${profileId}/`;
2743+
const body = await this.requestJSON(path, undefined, opts);
2744+
return TransactionProfileSchema.parse(body);
2745+
}
2746+
26792747
/**
26802748
* Retrieves raw profile chunk data for detailed analysis.
26812749
*

‎packages/mcp-core/src/api-client/schema.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,3 +1140,73 @@ export const ProfileChunkResponseSchema = z
11401140
chunks: z.array(ProfileChunkSchema),
11411141
})
11421142
.passthrough();
1143+
1144+
const ProfileReleaseSchema = z
1145+
.union([
1146+
z.string(),
1147+
z
1148+
.object({
1149+
version: z.string(),
1150+
})
1151+
.passthrough(),
1152+
z.null(),
1153+
])
1154+
.optional();
1155+
1156+
export const TransactionProfileSchema = z
1157+
.object({
1158+
event_id: z.string().optional(),
1159+
profile_id: z.string().optional(),
1160+
profiler_id: z.string().optional(),
1161+
environment: z.string().nullable().optional(),
1162+
platform: z.string(),
1163+
release: ProfileReleaseSchema,
1164+
version: z.union([z.string(), z.number()]).transform(String).optional(),
1165+
profile: ProfileChunkSchema.shape.profile,
1166+
transaction: z
1167+
.object({
1168+
name: z.string().optional(),
1169+
trace_id: z.string().optional(),
1170+
id: z.string().optional(),
1171+
active_thread_id: z.string().optional(),
1172+
relative_start_ns: z
1173+
.union([z.string(), z.number()])
1174+
.transform((value) => Number(value))
1175+
.optional(),
1176+
relative_end_ns: z
1177+
.union([z.string(), z.number()])
1178+
.transform((value) => Number(value))
1179+
.optional(),
1180+
})
1181+
.passthrough()
1182+
.optional(),
1183+
device: z
1184+
.object({
1185+
classification: z.string().nullable().optional(),
1186+
manufacturer: z.string().nullable().optional(),
1187+
locale: z.string().nullable().optional(),
1188+
model: z.string().nullable().optional(),
1189+
arch: z.string().nullable().optional(),
1190+
})
1191+
.passthrough()
1192+
.nullable()
1193+
.optional(),
1194+
os: z
1195+
.object({
1196+
name: z.string().nullable().optional(),
1197+
version: z.string().nullable().optional(),
1198+
build_number: z.string().nullable().optional(),
1199+
})
1200+
.passthrough()
1201+
.nullable()
1202+
.optional(),
1203+
client_sdk: z
1204+
.object({
1205+
name: z.string().nullable().optional(),
1206+
version: z.string().nullable().optional(),
1207+
})
1208+
.passthrough()
1209+
.nullable()
1210+
.optional(),
1211+
})
1212+
.passthrough();

‎packages/mcp-core/src/api-client/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import type {
8383
FlamegraphProfileMetadataSchema,
8484
ProfileChunkSchema,
8585
ProfileChunkResponseSchema,
86+
TransactionProfileSchema,
8687
ProfileFrameSchema,
8788
ProfileSampleSchema,
8889
} from "./schema";
@@ -147,6 +148,7 @@ export type FlamegraphProfileMetadata = z.infer<
147148
>;
148149
export type ProfileChunk = z.infer<typeof ProfileChunkSchema>;
149150
export type ProfileChunkResponse = z.infer<typeof ProfileChunkResponseSchema>;
151+
export type TransactionProfile = z.infer<typeof TransactionProfileSchema>;
150152
export type ProfileFrame = z.infer<typeof ProfileFrameSchema>;
151153
export type ProfileSample = z.infer<typeof ProfileSampleSchema>;
152154

‎packages/mcp-core/src/internal/issue-helpers.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,26 @@ describe("parseIssueParams", () => {
168168
});
169169
});
170170

171+
it("should allow matching organizationSlug with issueUrl", () => {
172+
expect(
173+
parseIssueParams({
174+
organizationSlug: "sentry",
175+
issueUrl: "https://sentry.io/sentry/issues/123",
176+
}),
177+
).toEqual({ organizationSlug: "sentry", issueId: "123" });
178+
});
179+
180+
it("should reject issueUrl outside the active organization constraint", () => {
181+
expect(() =>
182+
parseIssueParams({
183+
organizationSlug: "other-org",
184+
issueUrl: "https://sentry.io/sentry/issues/123",
185+
}),
186+
).toThrowErrorMatchingInlineSnapshot(
187+
`[UserInputError: Issue URL is outside the active organization constraint. Expected organization "other-org" but got "sentry".]`,
188+
);
189+
});
190+
171191
it("should throw if neither issueId nor issueUrl is provided", () => {
172192
expect(() =>
173193
parseIssueParams({ organizationSlug: "foo" }),

‎packages/mcp-core/src/internal/issue-helpers.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import { UserInputError } from "../errors";
11+
import { resolveScopedOrganizationSlug } from "./url-scope";
1112

1213
/**
1314
* Extracts the Sentry issue ID and organization slug from a full URL
@@ -141,7 +142,11 @@ export function parseIssueParams({
141142
);
142143
}
143144
return {
144-
...resolved,
145+
organizationSlug: resolveScopedOrganizationSlug({
146+
resourceLabel: "Issue",
147+
scopedOrganizationSlug: organizationSlug,
148+
urlOrganizationSlug: resolved.organizationSlug,
149+
}),
145150
issueId: parseIssueId(resolved.issueId),
146151
};
147152
}

‎packages/mcp-core/src/internal/tool-helpers/issue.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,58 @@
22
* Re-export of issue parsing utilities for tool modules.
33
* These utilities handle flexible input formats for Sentry issues.
44
*/
5+
import type { SentryApiService } from "../../api-client";
6+
import type { Issue } from "../../api-client/types";
7+
import { assertScopedProjectSlug } from "../url-scope";
8+
59
export { parseIssueParams } from "../../internal/issue-helpers";
610

711
/**
812
* Re-export of issue formatting utilities for tool modules.
913
*/
1014
export { formatIssueOutput } from "../../internal/formatting";
15+
16+
export function assertIssueWithinProjectConstraint({
17+
issue,
18+
projectSlug,
19+
resourceLabel = "Issue",
20+
}: {
21+
issue: Pick<Issue, "project">;
22+
projectSlug?: string | null;
23+
resourceLabel?: string;
24+
}): void {
25+
assertScopedProjectSlug({
26+
resourceLabel,
27+
scopedProjectSlug: projectSlug,
28+
actualProjectSlug: issue.project.slug,
29+
});
30+
}
31+
32+
export async function ensureIssueWithinProjectConstraint({
33+
apiService,
34+
organizationSlug,
35+
issueId,
36+
projectSlug,
37+
resourceLabel = "Issue",
38+
}: {
39+
apiService: SentryApiService;
40+
organizationSlug: string;
41+
issueId: string;
42+
projectSlug?: string | null;
43+
resourceLabel?: string;
44+
}): Promise<void> {
45+
if (!projectSlug) {
46+
return;
47+
}
48+
49+
const issue = await apiService.getIssue({
50+
organizationSlug,
51+
issueId,
52+
});
53+
54+
assertIssueWithinProjectConstraint({
55+
issue,
56+
projectSlug,
57+
resourceLabel,
58+
});
59+
}

0 commit comments

Comments
 (0)