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
74 changes: 74 additions & 0 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2865,4 +2865,78 @@ export class SentryApiService {

return response.chunks[0];
}

async getSnapshotDetails({
organizationSlug,
snapshotId,
compactMetadata = true,
}: {
organizationSlug: string;
snapshotId: string;
compactMetadata?: boolean;
}): Promise<unknown> {
const params = new URLSearchParams();
if (compactMetadata) {
params.set("compact_metadata", "true");
}
const path = `/organizations/${encodeURIComponent(organizationSlug)}/preprodartifacts/snapshots/${encodeURIComponent(snapshotId)}/?${params.toString()}`;
return this.requestJSON(path);
}

async getSnapshotImageDetail({
organizationSlug,
snapshotId,
imageIdentifier,
}: {
organizationSlug: string;
snapshotId: string;
imageIdentifier: string;
}): Promise<unknown> {
const path = `/organizations/${encodeURIComponent(organizationSlug)}/preprodartifacts/snapshots/${encodeURIComponent(snapshotId)}/images/${imageIdentifier}/`;
return this.requestJSON(path);
}

async fetchImageByUrl(
imageUrl: string,
): Promise<{ blob: Blob; contentType: string }> {
const response = imageUrl.startsWith("https://")
? await fetch(imageUrl)
: await this.request(
imageUrl.startsWith("/api/0")
? imageUrl.slice("/api/0".length)
: imageUrl,
);
if (!response.ok) {
throw new Error(
`Failed to fetch image: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
return {
blob,
contentType: response.headers.get("content-type") ?? "image/png",
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

async getLatestBaseSnapshot({
organizationSlug,
appId,
branch,
project,
compactMetadata = true,
}: {
organizationSlug: string;
appId: string;
branch?: string;
project?: string;
compactMetadata?: boolean;
}): Promise<unknown> {
const params = new URLSearchParams();
params.set("app_id", appId);
if (branch) params.set("branch", branch);
if (project) params.set("project", project);
if (compactMetadata) params.set("compact_metadata", "true");
const path = `/organizations/${encodeURIComponent(organizationSlug)}/preprodartifacts/snapshots/latest-base/?${params.toString()}`;
return this.requestJSON(path);
}
}
3 changes: 3 additions & 0 deletions packages/mcp-core/src/internal/blob-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function blobToBase64(blob: Blob): Promise<string> {
return Buffer.from(await blob.arrayBuffer()).toString("base64");
}
80 changes: 80 additions & 0 deletions packages/mcp-core/src/internal/url-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,86 @@ describe("parseSentryUrl", () => {
});
});

describe("snapshot URLs", () => {
it("parses snapshot URL with subdomain", () => {
expect(
parseSentryUrl("https://my-org.sentry.io/preprod/snapshots/231949/"),
).toMatchInlineSnapshot(`
{
"organizationSlug": "my-org",
"selectedSnapshot": undefined,
"snapshotId": "231949",
"type": "snapshot",
}
`);
});

it("parses snapshot URL with selectedSnapshot query param", () => {
expect(
parseSentryUrl(
"https://my-org.sentry.io/preprod/snapshots/231949/?selectedSnapshot=login_screen.png",
),
).toMatchInlineSnapshot(`
{
"organizationSlug": "my-org",
"selectedSnapshot": "login_screen.png",
"snapshotId": "231949",
"type": "snapshot",
}
`);
});

it("parses snapshot URL with encoded selectedSnapshot", () => {
expect(
parseSentryUrl(
"https://my-org.sentry.io/preprod/snapshots/241539/?selectedSnapshot=static%2Fapp%2Fcomponents%2Fcore%2Falert.png",
),
).toMatchInlineSnapshot(`
{
"organizationSlug": "my-org",
"selectedSnapshot": "static/app/components/core/alert.png",
"snapshotId": "241539",
"type": "snapshot",
}
`);
});

it("parses snapshot URL with organizations path", () => {
expect(
parseSentryUrl(
"https://sentry.io/organizations/my-org/preprod/snapshots/12345/",
),
).toMatchInlineSnapshot(`
{
"organizationSlug": "my-org",
"selectedSnapshot": undefined,
"snapshotId": "12345",
"type": "snapshot",
}
`);
});

it("parses snapshot URL without trailing slash", () => {
expect(
parseSentryUrl("https://my-org.sentry.io/preprod/snapshots/99999"),
).toMatchInlineSnapshot(`
{
"organizationSlug": "my-org",
"selectedSnapshot": undefined,
"snapshotId": "99999",
"type": "snapshot",
}
`);
});

it("returns unknown for /preprod/ without snapshots path", () => {
const result = parseSentryUrl(
"https://my-org.sentry.io/preprod/something-else/",
);
expect(result.type).toBe("unknown");
});
});

describe("performance summary URLs", () => {
it("extracts transaction from performance summary URL", () => {
expect(
Expand Down
68 changes: 68 additions & 0 deletions packages/mcp-core/src/internal/url-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type SentryResourceType =
| "replay"
| "monitor"
| "release"
| "snapshot"
| "unknown";

/**
Expand Down Expand Up @@ -56,6 +57,10 @@ export interface ParsedSentryUrl {
releaseVersion?: string;
/** Transaction name (from query param in performance URLs) */
transaction?: string;
/** Snapshot ID (for preprod snapshot URLs) */
snapshotId?: string;
Comment thread
cursor[bot] marked this conversation as resolved.
/** Selected snapshot image name (from ?selectedSnapshot= query param) */
selectedSnapshot?: string;
}

/**
Expand Down Expand Up @@ -158,6 +163,7 @@ function extractOrganizationSlug(parsedUrl: URL, pathParts: string[]): string {
"dashboards",
"discover",
"insights",
"preprod",
];
if (
pathParts.length > 1 &&
Expand Down Expand Up @@ -337,13 +343,75 @@ function identifyResource(
}
}

// Snapshot URL: /preprod/snapshots/{snapshotId}/
const preprodIndex = pathParts.indexOf("preprod");
if (preprodIndex !== -1 && pathParts[preprodIndex + 1] === "snapshots") {
const snapshotId = pathParts[preprodIndex + 2];
if (snapshotId) {
const selectedSnapshot =
parsedUrl.searchParams.get("selectedSnapshot") || undefined;
return {
type: "snapshot",
organizationSlug,
snapshotId,
selectedSnapshot,
};
}
}

// Could not identify resource type
return {
type: "unknown",
organizationSlug,
};
}

export function parseSnapshotUrl(url: string): {
organizationSlug: string;
snapshotId: string;
} | null {
try {
const parsed = parseSentryUrl(url);
if (parsed.type === "snapshot" && parsed.snapshotId) {
return {
organizationSlug: parsed.organizationSlug,
snapshotId: parsed.snapshotId,
};
}
Comment thread
cursor[bot] marked this conversation as resolved.
return null;
} catch {
return null;
}
}
Comment thread
NicoHinderling marked this conversation as resolved.

export function resolveSnapshotParams(params: {
snapshotUrl: string | null;
organizationSlug: string | null;
snapshotId: string | null;
}): { organizationSlug: string; snapshotId: string } {
let organizationSlug = params.organizationSlug;
let snapshotId = params.snapshotId;

if (params.snapshotUrl) {
const parsed = parseSnapshotUrl(params.snapshotUrl);
if (!parsed) {
throw new UserInputError(
`Could not parse snapshot URL: ${params.snapshotUrl}. Expected format: https://{org}.sentry.io/preprod/snapshots/{id}/`,
);
}
organizationSlug = organizationSlug || parsed.organizationSlug;
snapshotId = snapshotId || parsed.snapshotId;
}

if (!organizationSlug || !snapshotId) {
throw new UserInputError(
"Provide either snapshotUrl or both organizationSlug and snapshotId.",
);
}

return { organizationSlug, snapshotId };
}

/**
* Checks if a URL appears to be a Sentry profile URL.
*
Expand Down
47 changes: 41 additions & 6 deletions packages/mcp-core/src/skillDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
},
{
"name": "get_sentry_resource",
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, and breadcrumbs.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n</examples>",
"requiredScopes": ["event:read"]
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, breadcrumbs, and preprod snapshots.\nSnapshot URLs with ?selectedSnapshot= return the specific image and full metadata.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n\n### Snapshot diff summary\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/')\n\n### View a specific snapshot image\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/?selectedSnapshot=login_screen.png')\n</examples>",
"requiredScopes": ["event:read", "project:read"]
},
{
"name": "search_events",
Expand Down Expand Up @@ -99,8 +99,8 @@
},
{
"name": "get_sentry_resource",
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, and breadcrumbs.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n</examples>",
"requiredScopes": ["event:read"]
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, breadcrumbs, and preprod snapshots.\nSnapshot URLs with ?selectedSnapshot= return the specific image and full metadata.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n\n### Snapshot diff summary\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/')\n\n### View a specific snapshot image\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/?selectedSnapshot=login_screen.png')\n</examples>",
"requiredScopes": ["event:read", "project:read"]
},
{
"name": "search_events",
Expand Down Expand Up @@ -179,8 +179,8 @@
},
{
"name": "get_sentry_resource",
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, and breadcrumbs.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n</examples>",
"requiredScopes": ["event:read"]
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, breadcrumbs, and preprod snapshots.\nSnapshot URLs with ?selectedSnapshot= return the specific image and full metadata.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n\n### Snapshot diff summary\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/')\n\n### View a specific snapshot image\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/?selectedSnapshot=login_screen.png')\n</examples>",
"requiredScopes": ["event:read", "project:read"]
},
{
"name": "search_events",
Expand Down Expand Up @@ -263,5 +263,40 @@
"requiredScopes": []
}
]
},
{
"id": "preprod",
"name": "Preprod Snapshots",
"description": "Inspect visual regression snapshot tests from CI — view changed images and diff masks",
"defaultEnabled": false,
"order": 6,
"toolCount": 5,
"tools": [
{
"name": "find_organizations",
"description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
"requiredScopes": ["org:read"]
},
{
"name": "find_projects",
"description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
"requiredScopes": ["project:read"]
},
{
"name": "get_latest_base_snapshot",
"description": "Get the most recent base-build snapshot for a given app, returning a compact list of all images.\n\nUse this tool when you need to:\n- Find what the current UI looks like for an app (latest screenshots from the main/default branch)\n- Look up a specific screen or component from the most recent baseline build\n- Browse available screenshots before requesting specific images\n\nBase builds are snapshots on the default branch (no comparison). This endpoint returns\ncompact image metadata (display_name, image_file_name, group, description) for every image.\n\n<examples>\n### Get the latest base snapshot for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"com.emergetools.hackernews\")\n```\n\n### Get the latest base snapshot for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"com.emergetools.hackernews\", branch=\"main\")\n```\n</examples>\n\n<hints>\n- The response includes compact metadata per image. Scan the list to find images matching what you need.\n- To view a specific image, use get_sentry_resource(url='<snapshot_url>?selectedSnapshot=<image_file_name>').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n</hints>",
"requiredScopes": ["project:read"]
},
{
"name": "get_sentry_resource",
"description": "Fetch a Sentry resource by URL or by type and ID.\n\nSupports issues, events, traces, spans, replays, breadcrumbs, and preprod snapshots.\nSnapshot URLs with ?selectedSnapshot= return the specific image and full metadata.\nTrace lookups return a condensed overview by default.\n\nFor `resourceType='span'`, pass `resourceId` as `<traceId>:<spanId>`.\n\n<examples>\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n\n### Span by trace and span ID\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId='a4d1aae7216b47ff8117cf4e09ce9d0a:aa8e7f3384ef4ff5')\n\n### Replay by ID\nget_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')\n\n### Snapshot diff summary\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/')\n\n### View a specific snapshot image\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/?selectedSnapshot=login_screen.png')\n</examples>",
"requiredScopes": ["event:read", "project:read"]
},
{
"name": "whoami",
"description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
"requiredScopes": []
}
]
}
]
11 changes: 10 additions & 1 deletion packages/mcp-core/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type Skill =
| "triage"
| "project-management"
| "seer"
| "docs";
| "docs"
| "preprod";

// Central registry with metadata (used by OAuth UI)
export interface SkillDefinition {
Expand Down Expand Up @@ -60,6 +61,14 @@ export const SKILLS: Record<Skill, SkillDefinition> = {
defaultEnabled: false,
order: 5,
},
preprod: {
id: "preprod",
name: "Preprod Snapshots",
description:
"Inspect visual regression snapshot tests from CI — view changed images and diff masks",
defaultEnabled: false,
order: 6,
},
};

// Sorted array for UI ordering
Expand Down
Loading
Loading