diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 83613f8d5..568333fb4 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5312,8 +5312,50 @@ "eventId": { "type": "string" }, - "reporter": { - "type": "string" + "name": { + "description": "Name the reporter entered, falling back to the event user's name. Null when Sentry only has an email or username.", + "type": ["string", "null"] + }, + "email": { + "description": "Email on the report, falling back to the event user.", + "type": ["string", "null"] + }, + "user": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": ["string", "null"] + }, + "username": { + "type": ["string", "null"] + }, + "name": { + "description": "Null when Sentry only has an email or username.", + "type": ["string", "null"] + }, + "email": { + "type": ["string", "null"] + }, + "ipAddress": { + "type": ["string", "null"] + } + }, + "required": [ + "id", + "username", + "name", + "email", + "ipAddress" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "The user on the linked event. Null when the event has none." }, "comments": { "type": "string" @@ -5325,7 +5367,9 @@ "required": [ "id", "eventId", - "reporter", + "name", + "email", + "user", "comments", "dateCreated" ], diff --git a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts index fb7251637..ad829f513 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts @@ -51,30 +51,42 @@ describe("get_issue_user_reports", () => { { "comments": "i am currently testing it", "dateCreated": "2026-06-22T02:37:27.878Z", + "email": "reporter@example.com", "eventId": "957c1ee8f31e4e68a57b637dad557b32", "id": "11974227", - "reporter": "Example Reporter", + "name": "Example Reporter", + "user": { + "email": "reporter@example.com", + "id": "user-123", + "ipAddress": "203.0.113.10", + "name": "Example Reporter", + "username": "example-reporter", + }, }, { "comments": "anonymous report with no event user", "dateCreated": "2026-06-22T03:10:11.123Z", + "email": null, "eventId": "057c1ee8f31e4e68a57b637dad557b33", "id": "11974228", - "reporter": "anonymous", + "name": null, + "user": null, }, { "comments": "event user shell without identity", "dateCreated": "2026-06-22T03:45:12.456Z", + "email": null, "eventId": "157c1ee8f31e4e68a57b637dad557b34", "id": "11974229", - "reporter": "anonymous", + "name": null, + "user": null, }, ], } `); }); - it("returns the next cursor without leaking IP address or avatar URL", async () => { + it("returns the event user's identity and IP address with the next cursor", async () => { mswServer.use( http.get( "https://sentry.io/api/0/organizations/:org/issues/:issueId/user-reports/", @@ -87,7 +99,9 @@ describe("get_issue_user_reports", () => { { id: "11974229", eventID: "157c1ee8f31e4e68a57b637dad557b34", - name: "", + // Sentry fills both names from the username when no real + // name exists. + name: "event-user", email: "", comments: "reported by event user", dateCreated: "2026-06-22T04:20:21.987654Z", @@ -95,7 +109,7 @@ describe("get_issue_user_reports", () => { id: "user-456", username: "event-user", email: null, - name: null, + name: "event-user", ipAddress: "203.0.113.7", avatarUrl: "https://example.com/avatar.png", }, @@ -136,15 +150,78 @@ describe("get_issue_user_reports", () => { { "comments": "reported by event user", "dateCreated": "2026-06-22T04:20:21.987Z", + "email": null, "eventId": "157c1ee8f31e4e68a57b637dad557b34", "id": "11974229", - "reporter": "event-user", + "name": null, + "user": { + "email": null, + "id": "user-456", + "ipAddress": "203.0.113.7", + "name": null, + "username": "event-user", + }, }, ], } `); - const serialized = JSON.stringify(structuredContent); - expect(serialized).not.toContain("203.0.113.7"); - expect(serialized).not.toContain("avatar.png"); + // The avatar URL is presentation data, not user data the report stored. + expect(JSON.stringify(structuredContent)).not.toContain("avatar.png"); + }); + + it("returns null names when Sentry only copied the reporter email", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:org/issues/:issueId/user-reports/", + () => + HttpResponse.json([ + { + id: "11974230", + eventID: "257c1ee8f31e4e68a57b637dad557b35", + name: "jane@example.com", + email: "jane@example.com", + comments: "no name given", + dateCreated: "2026-06-22T05:00:00.000000Z", + user: { + id: "user-789", + username: null, + email: "jane@example.com", + name: "jane@example.com", + ipAddress: "198.51.100.4", + avatarUrl: null, + }, + event: { + id: "257c1ee8f31e4e68a57b637dad557b35", + eventID: "257c1ee8f31e4e68a57b637dad557b35", + }, + }, + ]), + ), + ); + + const result = await getIssueUserReports.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + issueId: "CLOUDFLARE-MCP-41", + cursor: null, + limit: 25, + }, + context, + ); + + const [report] = getIssueUserReportsOutputSchema.parse( + getStructuredContent(result), + ).reports; + expect(report).toMatchObject({ + name: null, + email: "jane@example.com", + user: { + id: "user-789", + name: null, + email: "jane@example.com", + ipAddress: "198.51.100.4", + }, + }); }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.ts b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.ts index 7993e0fa8..4c197ea1d 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.ts @@ -17,24 +17,62 @@ import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; import { formatDate } from "./support/api-formatting"; -function getReporter(report: UserReportList[number]): string { - const userId = report.user?.id?.trim(); - // Older Sentry serializers can emit sentinel strings when no user ID exists. - const userIdReporter = - userId && !["none", "null"].includes(userId.toLowerCase()) - ? `user:${userId}` - : null; - return ( - report.name?.trim() || - report.email?.trim() || - report.user?.name?.trim() || - report.user?.email?.trim() || - report.user?.username?.trim() || - userIdReporter || - "anonymous" - ); +type UserReport = UserReportList[number]; + +function orNull(value: string | null | undefined): string | null { + return value?.trim() || null; +} + +/** + * Sentry fills display names from the email or username when no real name + * exists, so treat a name that just repeats one of those as missing. + */ +function realName( + value: string | null | undefined, + identities: Array, +): string | null { + const name = orNull(value); + if (!name) { + return null; + } + const lowered = name.toLowerCase(); + return identities.some((identity) => identity?.toLowerCase() === lowered) + ? null + : name; } +function mapEventUser(user: UserReport["user"]) { + if (!user) { + return null; + } + const id = orNull(user.id); + const username = orNull(user.username); + const email = orNull(user.email); + const mapped = { + // Older Sentry serializers can emit sentinel strings when no user ID exists. + id: id && !["none", "null"].includes(id.toLowerCase()) ? id : null, + username, + name: realName(user.name, [email, username]), + email, + ipAddress: orNull(user.ipAddress), + }; + return Object.values(mapped).some((value) => value !== null) ? mapped : null; +} + +const eventUserSchema = z + .object({ + id: z.string().nullable(), + username: z.string().nullable(), + name: z + .string() + .nullable() + .describe("Null when Sentry only has an email or username."), + email: z.string().nullable(), + ipAddress: z.string().nullable(), + }) + .nullable() + .describe("The user on the linked event. Null when the event has none."); + export const getIssueUserReportsOutputSchema = z.object({ organizationSlug: z.string(), issueId: z.string(), @@ -42,7 +80,17 @@ export const getIssueUserReportsOutputSchema = z.object({ z.object({ id: z.string(), eventId: z.string(), - reporter: z.string(), + name: z + .string() + .nullable() + .describe( + "Name the reporter entered, falling back to the event user's name. Null when Sentry only has an email or username.", + ), + email: z + .string() + .nullable() + .describe("Email on the report, falling back to the event user."), + user: eventUserSchema, comments: z.string(), dateCreated: z.string().nullable(), }), @@ -53,11 +101,22 @@ export const getIssueUserReportsOutputSchema = z.object({ .describe("Pass as cursor with the same issue scope for the next page."), }); -function mapUserReport(report: UserReportList[number]) { +function mapUserReport(report: UserReport) { + const user = mapEventUser(report.user); + const email = orNull(report.email) ?? user?.email ?? null; return { id: report.id, eventId: report.eventID, - reporter: getReporter(report), + name: + realName(report.name, [ + email, + user?.email ?? null, + user?.username ?? null, + ]) ?? + user?.name ?? + null, + email, + user, comments: report.comments, dateCreated: formatDate(report.dateCreated), };