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
83 changes: 78 additions & 5 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ type RequestOptions = {
allowStatuses?: number[];
};

/**
* Default cap for returning attachment bytes inline over the MCP transport.
* A 30 MB attachment takes ~80 MB in memory once base64-encoded and serialized,
* which fits the Cloudflare Worker's 128 MB isolate with room for the rest of the
* request; above it, getEventAttachment returns a reference instead of the bytes.
*/
export const DEFAULT_MAX_INLINE_ATTACHMENT_BYTES = 30 * 1024 * 1024;

export type TraceItemType = "spans" | "logs" | "tracemetrics";

type ClientKeyRateLimit = {
Expand Down Expand Up @@ -3839,18 +3847,32 @@ export class SentryApiService {
projectSlug,
eventId,
attachmentId,
maxInlineBytes = DEFAULT_MAX_INLINE_ATTACHMENT_BYTES,
}: {
organizationSlug: string;
projectSlug: string;
eventId: string;
attachmentId: string;
/**
* Attachments larger than this (by metadata `size`) skip download —
* `getEventAttachment` returns `bytes: null`. Defaults to
* {@link DEFAULT_MAX_INLINE_ATTACHMENT_BYTES}.
*/
maxInlineBytes?: number;
Comment thread
betegon marked this conversation as resolved.
},
opts?: RequestOptions,
): Promise<{
attachment: EventAttachment;
/** Authenticated API download URL (requires credentials). */
downloadUrl: string;
/**
* Presigned URL that downloads the file directly without credentials, or
* `null` when unavailable. Only set on the skipped-download path.
*/
directDownloadUrl: string | null;
filename: string;
blob: Blob;
/** Raw file bytes, or `null` when the download was skipped. */
bytes: Uint8Array | null;
contentType: string;
}> {
// Get the attachment metadata first
Expand All @@ -3869,12 +3891,31 @@ export class SentryApiService {
);
}

// Download the actual file content
const downloadUrl =
const downloadPath =
apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/${attachmentId}/` +
`?download=1`;

// Skip the download for oversized attachments — buffering and base64
// encoding them can exhaust the worker's memory. Decide from the metadata
// size so we never issue the request.
if (attachment.size > maxInlineBytes) {
const directDownloadUrl = await this.resolveDirectDownloadUrl(
downloadPath,
opts,
);
return {
attachment,
downloadUrl: `${this.apiPrefix}${downloadPath}`,
directDownloadUrl,
filename: attachment.name,
bytes: null,
contentType: attachment.mimetype || "application/octet-stream",
};
}

// Download the actual file content
const downloadResponse = await this.request(
downloadUrl,
downloadPath,
{ method: "GET" },
opts,
);
Expand All @@ -3887,15 +3928,47 @@ export class SentryApiService {
attachment.mimetype ||
"application/octet-stream";

// Read the body once as bytes; arrayBuffer() avoids the extra copy that
// blob() + blob.arrayBuffer() would hold at the same time.
const bytes = new Uint8Array(await downloadResponse.arrayBuffer());

return {
attachment,
downloadUrl: downloadResponse.url,
directDownloadUrl: null,
filename: attachment.name,
blob: await downloadResponse.blob(),
bytes,
contentType,
};
}

/**
* Probe the download endpoint for a presigned URL without buffering the file.
*
* Objectstore attachments redirect to a presigned URL (returned here); legacy
* ones stream a 200 with no redirect, so there is no direct URL. The body is
* discarded either way — we only need the URL.
*/
private async resolveDirectDownloadUrl(
downloadPath: string,
opts?: RequestOptions,
): Promise<string | null> {
try {
const response = await this.request(
downloadPath,
{ method: "GET" },
Comment thread
betegon marked this conversation as resolved.
opts,
);
const directUrl = response.redirected ? response.url : null;
// Don't await cancel(): we already have the URL, and awaiting it can
// stall on some runtimes.
void response.body?.cancel().catch(() => {});
return directUrl;
} catch {
return null;
}
}

async getReplayDetails(
{
organizationSlug,
Expand Down
12 changes: 12 additions & 0 deletions packages/mcp-core/src/internal/blob-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ export async function blobToBase64(blob: Blob): Promise<string> {
return Buffer.from(await blob.arrayBuffer()).toString("base64");
}

/**
* Base64-encode raw bytes without an intermediate copy.
*
* `Buffer.from(bytes.buffer, …)` views the existing memory instead of copying
* it. Prefer this over a `Blob` round-trip when you already hold a `Uint8Array`.
*/
export function bytesToBase64(bytes: Uint8Array): string {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString(
"base64",
);
}

export interface ImagePreviewOptions {
maxDimension?: number;
jpegQuality?: number;
Expand Down
119 changes: 119 additions & 0 deletions packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,125 @@ describe("get_event_attachment", () => {
});
});

it("decodes and inlines a text/plain (.log) attachment", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "e49541c747cb4d8aa3efb70ca5aba245",
attachmentId: "789",
regionUrl: null,
},
{
constraints: { organizationSlug: null, projectSlug: null },
accessToken: "access-token",
userId: "1",
},
);

// Text files are inlined as a single text block — no image/resource part.
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type: "text" });
const text = (result[0] as { text: string }).text;
expect(text).toContain("**MIME Type:** text/plain");
expect(text).toContain("## File Content");
expect(text).toContain("INFO app started");
expect(text).toContain("ERROR db connection failed");
});

it("returns a presigned direct-download URL for an oversized objectstore attachment", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "f49541c747cb4d8aa3efb70ca5aba246",
attachmentId: "999",
regionUrl: null,
},
{
constraints: { organizationSlug: null, projectSlug: null },
accessToken: "access-token",
userId: "1",
},
);

// Oversized objectstore: no bytes downloaded; a guidance string leading with
// the presigned direct link (no auth) is returned.
expect(typeof result).toBe("string");
const text = result as string;
expect(text).toContain("Too Large to Return Inline");
expect(text).toContain("50.0 MB");
expect(text).toContain("30.0 MB");
expect(text).toContain("Direct download");
expect(text).toContain("no authentication needed");
expect(text).toContain(
"https://objectstore.example.test/attachments/999/blob?sig=test-signature",
);
// The authenticated API endpoint is still offered as a fallback.
expect(text).toContain(
"sentry api projects/sentry-mcp-evals/cloudflare-mcp/events/f49541c747cb4d8aa3efb70ca5aba246/attachments/999/?download=1",
);
});

it("falls back to authenticated-download instructions for an oversized legacy attachment", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "h49541c747cb4d8aa3efb70ca5aba248",
attachmentId: "222",
regionUrl: null,
},
{
constraints: { organizationSlug: null, projectSlug: null },
accessToken: "access-token",
userId: "1",
},
);

// Oversized legacy (no presigned URL): auth-required guidance only.
expect(typeof result).toBe("string");
const text = result as string;
expect(text).toContain("Too Large to Return Inline");
expect(text).toContain("40.0 MB");
expect(text).toContain("requires your Sentry credentials");
expect(text).not.toContain("objectstore.example.test");

// The uploader-controlled filename must not be interpolated into the shell
// command — the redirect target uses the validated attachment ID instead.
expect(text).toContain(
"sentry api projects/sentry-mcp-evals/cloudflare-mcp/events/h49541c747cb4d8aa3efb70ca5aba248/attachments/222/?download=1 > attachment-222",
);
expect(text).not.toContain("?download=1 > pwn");
});

it("flags an empty download body when metadata reports a non-empty file", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "g49541c747cb4d8aa3efb70ca5aba247",
attachmentId: "111",
regionUrl: null,
},
{
constraints: { organizationSlug: null, projectSlug: null },
accessToken: "access-token",
userId: "1",
},
);

// Zero-byte body: no binary/image part is emitted; the text flags the gap.
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type: "text" });
const text = (result[0] as { text: string }).text;
expect(text).toContain("## Empty Content");
expect(text).toContain("returned no data");
expect(text).toContain("1024 bytes");
});

it("throws error for malformed regionUrl", async () => {
await expect(
getEventAttachment.handler(
Expand Down
Loading
Loading