Skip to content

Commit 8311480

Browse files
Add event attachment download support (#358)
Attachments can be listed and downloaded. Text attachments are returned inline with the text response. Binary attachments are included as embedded resource. Co-authored-by: Sean Houghton <[email protected]>
1 parent 23f5cdf commit 8311480

File tree

13 files changed

+461
-18
lines changed

13 files changed

+461
-18
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"id": "123",
4+
"name": "screenshot.png",
5+
"type": "event.attachment",
6+
"size": 1024,
7+
"mimetype": "image/png",
8+
"dateCreated": "2025-04-08T21:15:04.000Z",
9+
"sha1": "abc123def456",
10+
"headers": {
11+
"Content-Type": "image/png"
12+
}
13+
}
14+
]

packages/mcp-server-mocks/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { http, HttpResponse } from "msw";
2727
import autofixStateFixture from "./fixtures/autofix-state.json";
2828
import issueFixture from "./fixtures/issue.json";
2929
import eventsFixture from "./fixtures/event.json";
30+
import eventAttachmentsFixture from "./fixtures/event-attachments.json";
3031
import tagsFixture from "./fixtures/tags.json";
3132
import projectFixture from "./fixtures/project.json";
3233
import teamFixture from "./fixtures/team.json";
@@ -897,6 +898,25 @@ export const restHandlers = buildHandlers([
897898
return HttpResponse.json(updatedIssue);
898899
},
899900
},
901+
// Event attachment endpoints
902+
{
903+
method: "get",
904+
path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/events/7ca573c0f4814912aaa9bdc77d1a7d51/attachments/",
905+
fetch: () => HttpResponse.json(eventAttachmentsFixture),
906+
},
907+
{
908+
method: "get",
909+
path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/events/7ca573c0f4814912aaa9bdc77d1a7d51/attachments/123/",
910+
fetch: () => {
911+
// Mock attachment blob response
912+
const mockBlob = new Blob(["fake image data"], { type: "image/png" });
913+
return new HttpResponse(mockBlob, {
914+
headers: {
915+
"Content-Type": "image/png",
916+
},
917+
});
918+
},
919+
},
900920
]);
901921

902922
// Add handlers for mcp.sentry.dev and localhost

packages/mcp-server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@
7979
"dev": "pnpm run generate-tool-definitions && tsc -w",
8080
"start": "tsx src/index.ts",
8181
"prepare": "pnpm run build",
82-
"test": "vitest",
83-
"test:ci": "vitest run --coverage --reporter=junit --outputFile=tests.junit.xml",
82+
"test": "pnpm run generate-tool-definitions && vitest",
83+
"test:ci": "pnpm run generate-tool-definitions && vitest run --coverage --reporter=junit --outputFile=tests.junit.xml",
8484
"tsc": "tsc --noEmit",
8585
"test:watch": "vitest watch",
8686
"generate-tool-definitions": "tsx scripts/generate-tool-definitions.ts"

packages/mcp-server/src/api-client/client.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
IssueListSchema,
1010
IssueSchema,
1111
EventSchema,
12+
EventAttachmentListSchema,
13+
EventAttachmentSchema,
1214
ErrorsSearchResponseSchema,
1315
SpansSearchResponseSchema,
1416
TagListSchema,
@@ -25,6 +27,8 @@ import type {
2527
ClientKey,
2628
ClientKeyList,
2729
Event,
30+
EventAttachment,
31+
EventAttachmentList,
2832
Issue,
2933
IssueList,
3034
OrganizationList,
@@ -871,6 +875,86 @@ export class SentryApiService {
871875
);
872876
}
873877

878+
async listEventAttachments(
879+
{
880+
organizationSlug,
881+
projectSlug,
882+
eventId,
883+
}: {
884+
organizationSlug: string;
885+
projectSlug: string;
886+
eventId: string;
887+
},
888+
opts?: RequestOptions,
889+
) {
890+
const response = await this.request(
891+
`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
892+
undefined,
893+
opts,
894+
);
895+
896+
const body = await response.json();
897+
return EventAttachmentListSchema.parse(body);
898+
}
899+
900+
async getEventAttachment(
901+
{
902+
organizationSlug,
903+
projectSlug,
904+
eventId,
905+
attachmentId,
906+
}: {
907+
organizationSlug: string;
908+
projectSlug: string;
909+
eventId: string;
910+
attachmentId: string;
911+
},
912+
opts?: RequestOptions,
913+
): Promise<{
914+
attachment: any;
915+
downloadUrl: string;
916+
filename: string;
917+
blob: Blob;
918+
}> {
919+
// Get the attachment metadata first
920+
const attachmentsResponse = await this.request(
921+
`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`,
922+
undefined,
923+
opts,
924+
);
925+
926+
const attachments = EventAttachmentListSchema.parse(
927+
await attachmentsResponse.json(),
928+
);
929+
const attachment = attachments.find((att) => att.id === attachmentId);
930+
931+
if (!attachment) {
932+
throw new Error(
933+
`Attachment with ID ${attachmentId} not found for event ${eventId}`,
934+
);
935+
}
936+
937+
// Download the actual file content
938+
const downloadUrl = `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/${attachmentId}/?download=1`;
939+
const downloadResponse = await this.request(
940+
downloadUrl,
941+
{
942+
method: "GET",
943+
headers: {
944+
Accept: "application/octet-stream",
945+
},
946+
},
947+
opts,
948+
);
949+
950+
return {
951+
attachment,
952+
downloadUrl: downloadResponse.url,
953+
filename: attachment.name,
954+
blob: await downloadResponse.blob(),
955+
};
956+
}
957+
874958
async updateIssue(
875959
{
876960
organizationSlug,

packages/mcp-server/src/api-client/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,16 @@ export const AutofixRunStateSchema = z.object({
533533
.passthrough()
534534
.nullable(),
535535
});
536+
537+
export const EventAttachmentSchema = z.object({
538+
id: z.string(),
539+
name: z.string(),
540+
type: z.string(),
541+
size: z.number(),
542+
mimetype: z.string(),
543+
dateCreated: z.string().datetime(),
544+
sha1: z.string(),
545+
headers: z.record(z.string(), z.string()).optional(),
546+
});
547+
548+
export const EventAttachmentListSchema = z.array(EventAttachmentSchema);

packages/mcp-server/src/api-client/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import type {
4646
ClientKeyListSchema,
4747
ClientKeySchema,
4848
EventSchema,
49+
EventAttachmentSchema,
50+
EventAttachmentListSchema,
4951
IssueListSchema,
5052
IssueSchema,
5153
OrganizationListSchema,
@@ -69,6 +71,7 @@ export type ClientKey = z.infer<typeof ClientKeySchema>;
6971
export type Release = z.infer<typeof ReleaseSchema>;
7072
export type Issue = z.infer<typeof IssueSchema>;
7173
export type Event = z.infer<typeof EventSchema>;
74+
export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
7275
export type Tag = z.infer<typeof TagSchema>;
7376
export type AutofixRun = z.infer<typeof AutofixRunSchema>;
7477
export type AutofixRunState = z.infer<typeof AutofixRunStateSchema>;
@@ -79,5 +82,6 @@ export type TeamList = z.infer<typeof TeamListSchema>;
7982
export type ProjectList = z.infer<typeof ProjectListSchema>;
8083
export type ReleaseList = z.infer<typeof ReleaseListSchema>;
8184
export type IssueList = z.infer<typeof IssueListSchema>;
85+
export type EventAttachmentList = z.infer<typeof EventAttachmentListSchema>;
8286
export type TagList = z.infer<typeof TagListSchema>;
8387
export type ClientKeyList = z.infer<typeof ClientKeyListSchema>;

packages/mcp-server/src/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,10 @@ export const ParamSentryGuide = z
114114
"Optional guide filter to limit search results to specific documentation sections. " +
115115
"Use either a platform (e.g., 'javascript', 'python') or platform/guide combination (e.g., 'javascript/nextjs', 'python/django').",
116116
);
117+
118+
export const ParamEventId = z.string().trim().describe("The ID of the event.");
119+
120+
export const ParamAttachmentId = z
121+
.string()
122+
.trim()
123+
.describe("The ID of the attachment to download.");

packages/mcp-server/src/server.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,9 @@ export async function configureServer({
300300
return {
301301
messages: [
302302
{
303-
role: "user",
303+
role: "user" as const,
304304
content: {
305-
type: "text",
305+
type: "text" as const,
306306
text: output,
307307
},
308308
},
@@ -361,14 +361,24 @@ export async function configureServer({
361361
span.setStatus({
362362
code: 1, // ok
363363
});
364-
return {
365-
content: [
366-
{
367-
type: "text" as const,
368-
text: String(output),
369-
},
370-
],
371-
};
364+
// if the tool returns a string, assume it's a message
365+
if (typeof output === "string") {
366+
return {
367+
content: [
368+
{
369+
type: "text" as const,
370+
text: output,
371+
},
372+
],
373+
};
374+
}
375+
// if the tool returns a list, assume it's a content list
376+
if (Array.isArray(output)) {
377+
return {
378+
content: output,
379+
};
380+
}
381+
throw new Error(`Invalid tool output: ${output}`);
372382
} catch (error) {
373383
span.setStatus({
374384
code: 2, // error
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect } from "vitest";
2+
import getEventAttachment from "./get-event-attachment.js";
3+
4+
describe("get_event_attachment", () => {
5+
it("lists attachments for an event", async () => {
6+
const result = await getEventAttachment.handler(
7+
{
8+
organizationSlug: "sentry-mcp-evals",
9+
projectSlug: "cloudflare-mcp",
10+
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
11+
attachmentId: undefined,
12+
regionUrl: undefined,
13+
},
14+
{
15+
accessToken: "access-token",
16+
userId: "1",
17+
organizationSlug: null,
18+
},
19+
);
20+
expect(result).toMatchInlineSnapshot(`
21+
"# Event Attachments
22+
23+
**Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
24+
**Project:** cloudflare-mcp
25+
26+
Found 1 attachment(s):
27+
28+
## Attachment 1
29+
30+
**ID:** 123
31+
**Name:** screenshot.png
32+
**Type:** event.attachment
33+
**Size:** 1024 bytes
34+
**MIME Type:** image/png
35+
**Created:** 2025-04-08T21:15:04.000Z
36+
**SHA1:** abc123def456
37+
38+
To download this attachment, use the "get_event_attachment" tool with the attachmentId provided:
39+
\`get_event_attachment(organizationSlug="sentry-mcp-evals", projectSlug="cloudflare-mcp", eventId="7ca573c0f4814912aaa9bdc77d1a7d51", attachmentId="123")\`
40+
41+
"
42+
`);
43+
});
44+
45+
it("downloads a specific attachment by ID", async () => {
46+
const result = await getEventAttachment.handler(
47+
{
48+
organizationSlug: "sentry-mcp-evals",
49+
projectSlug: "cloudflare-mcp",
50+
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
51+
attachmentId: "123",
52+
regionUrl: undefined,
53+
},
54+
{
55+
accessToken: "access-token",
56+
userId: "1",
57+
organizationSlug: null,
58+
},
59+
);
60+
61+
// Should return an array with both text description and image content
62+
expect(Array.isArray(result)).toBe(true);
63+
expect(result).toHaveLength(2);
64+
65+
// First item should be the image content
66+
expect(result[0]).toMatchObject({
67+
type: "image",
68+
mimeType: "image/png",
69+
data: expect.any(String), // base64 encoded data
70+
});
71+
72+
// Second item should be the text description
73+
expect(result[1]).toMatchInlineSnapshot(`
74+
{
75+
"text": "# Event Attachment Download
76+
77+
**Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
78+
**Attachment ID:** 123
79+
**Filename:** screenshot.png
80+
**Type:** event.attachment
81+
**Size:** 1024 bytes
82+
**MIME Type:** image/png
83+
**Created:** 2025-04-08T21:15:04.000Z
84+
**SHA1:** abc123def456
85+
86+
**Download URL:** https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/events/7ca573c0f4814912aaa9bdc77d1a7d51/attachments/123/?download=1
87+
88+
## Binary Content
89+
90+
The attachment is included as a resource and accessible through your client.
91+
",
92+
"type": "text",
93+
}
94+
`);
95+
});
96+
97+
it("throws error for malformed regionUrl", async () => {
98+
await expect(
99+
getEventAttachment.handler(
100+
{
101+
organizationSlug: "sentry-mcp-evals",
102+
projectSlug: "cloudflare-mcp",
103+
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
104+
attachmentId: undefined,
105+
regionUrl: "https",
106+
},
107+
{
108+
accessToken: "access-token",
109+
userId: "1",
110+
organizationSlug: null,
111+
},
112+
),
113+
).rejects.toThrow(
114+
"Invalid regionUrl provided: https. Must be a valid URL.",
115+
);
116+
});
117+
});

0 commit comments

Comments
 (0)