Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions packages/mcp-core/src/toolDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -5325,7 +5367,9 @@
"required": [
"id",
"eventId",
"reporter",
"name",
"email",
"user",
"comments",
"dateCreated"
],
Expand Down
97 changes: 87 additions & 10 deletions packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand All @@ -87,15 +99,17 @@ 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",
user: {
id: "user-456",
username: "event-user",
email: null,
name: null,
name: "event-user",
ipAddress: "203.0.113.7",
avatarUrl: "https://example.com/avatar.png",
},
Expand Down Expand Up @@ -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",
},
});
});
});
97 changes: 78 additions & 19 deletions packages/mcp-core/src/tools/catalog/get-issue-user-reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,80 @@ 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>,
): 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(),
reports: z.array(
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(),
}),
Expand All @@ -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),
};
Expand Down
Loading