From 196881efddf43af8a09cdce4a74ef3c72b56cf7c Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Wed, 13 May 2026 16:08:00 -0700 Subject: [PATCH] feat(mcp-core): Add preprod snapshot tools for browsing and viewing snapshots Adds two tools for preprod snapshot support: - `get_latest_base_snapshot` (public): Fetches the most recent base-build snapshot for an app, returning compact image metadata for browsing - `get_snapshot_details` (internal): Returns snapshot comparison summaries with diff info, and fetches specific images when `selectedSnapshot` is provided Snapshot image viewing is consolidated behind `get_sentry_resource` using the `?selectedSnapshot=` URL pattern from Sentry's UI, avoiding a separate public tool. Also includes: shared `blobToBase64` utility using efficient `Buffer.from()`, snapshot URL parsing with unit tests, `project:read` scope on `get_sentry_resource`, and absolute URL guard in `fetchImageByUrl`. Co-Authored-By: Claude Opus 4.6 --- packages/mcp-core/src/api-client/client.ts | 74 +++ packages/mcp-core/src/internal/blob-utils.ts | 3 + .../mcp-core/src/internal/url-helpers.test.ts | 80 ++++ packages/mcp-core/src/internal/url-helpers.ts | 68 +++ packages/mcp-core/src/skillDefinitions.json | 47 +- packages/mcp-core/src/skills.ts | 11 +- packages/mcp-core/src/toolDefinitions.json | 64 ++- .../src/tools/get-event-attachment.ts | 13 +- .../tools/get-latest-base-snapshot.test.ts | 151 ++++++ .../src/tools/get-latest-base-snapshot.ts | 140 ++++++ .../mcp-core/src/tools/get-sentry-resource.ts | 43 +- .../src/tools/get-snapshot-details.test.ts | 439 ++++++++++++++++++ .../src/tools/get-snapshot-details.ts | 333 +++++++++++++ packages/mcp-core/src/tools/index.ts | 4 + packages/mcp-server/src/cli/resolve.test.ts | 8 +- .../agents/sentry-mcp.md | 1 + plugins/sentry-mcp/agents/sentry-mcp.md | 1 + 17 files changed, 1455 insertions(+), 25 deletions(-) create mode 100644 packages/mcp-core/src/internal/blob-utils.ts create mode 100644 packages/mcp-core/src/tools/get-latest-base-snapshot.test.ts create mode 100644 packages/mcp-core/src/tools/get-latest-base-snapshot.ts create mode 100644 packages/mcp-core/src/tools/get-snapshot-details.test.ts create mode 100644 packages/mcp-core/src/tools/get-snapshot-details.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 4a64718c2..b27845d72 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2865,4 +2865,78 @@ export class SentryApiService { return response.chunks[0]; } + + async getSnapshotDetails({ + organizationSlug, + snapshotId, + compactMetadata = true, + }: { + organizationSlug: string; + snapshotId: string; + compactMetadata?: boolean; + }): Promise { + 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 { + 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", + }; + } + + async getLatestBaseSnapshot({ + organizationSlug, + appId, + branch, + project, + compactMetadata = true, + }: { + organizationSlug: string; + appId: string; + branch?: string; + project?: string; + compactMetadata?: boolean; + }): Promise { + 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); + } } diff --git a/packages/mcp-core/src/internal/blob-utils.ts b/packages/mcp-core/src/internal/blob-utils.ts new file mode 100644 index 000000000..88a291191 --- /dev/null +++ b/packages/mcp-core/src/internal/blob-utils.ts @@ -0,0 +1,3 @@ +export async function blobToBase64(blob: Blob): Promise { + return Buffer.from(await blob.arrayBuffer()).toString("base64"); +} diff --git a/packages/mcp-core/src/internal/url-helpers.test.ts b/packages/mcp-core/src/internal/url-helpers.test.ts index 1bc78f217..5856caeb1 100644 --- a/packages/mcp-core/src/internal/url-helpers.test.ts +++ b/packages/mcp-core/src/internal/url-helpers.test.ts @@ -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( diff --git a/packages/mcp-core/src/internal/url-helpers.ts b/packages/mcp-core/src/internal/url-helpers.ts index fb1aac568..74a37a615 100644 --- a/packages/mcp-core/src/internal/url-helpers.ts +++ b/packages/mcp-core/src/internal/url-helpers.ts @@ -19,6 +19,7 @@ export type SentryResourceType = | "replay" | "monitor" | "release" + | "snapshot" | "unknown"; /** @@ -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; + /** Selected snapshot image name (from ?selectedSnapshot= query param) */ + selectedSnapshot?: string; } /** @@ -158,6 +163,7 @@ function extractOrganizationSlug(parsedUrl: URL, pathParts: string[]): string { "dashboards", "discover", "insights", + "preprod", ]; if ( pathParts.length > 1 && @@ -337,6 +343,22 @@ 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", @@ -344,6 +366,52 @@ function identifyResource( }; } +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, + }; + } + return null; + } catch { + return null; + } +} + +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. * diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 056e09082..c9253e88c 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -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 `:`.\n\n\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", - "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 `:`.\n\n\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", + "requiredScopes": ["event:read", "project:read"] }, { "name": "search_events", @@ -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 `:`.\n\n\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", - "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 `:`.\n\n\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", + "requiredScopes": ["event:read", "project:read"] }, { "name": "search_events", @@ -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 `:`.\n\n\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", - "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 `:`.\n\n\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", + "requiredScopes": ["event:read", "project:read"] }, { "name": "search_events", @@ -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\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\n\n\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='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "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 `:`.\n\n\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", + "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": [] + } + ] } ] diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index 27f5c7c36..82f392d2e 100644 --- a/packages/mcp-core/src/skills.ts +++ b/packages/mcp-core/src/skills.ts @@ -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 { @@ -60,6 +61,14 @@ export const SKILLS: Record = { 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 diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 2abb1fac5..5523f7c79 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -477,6 +477,66 @@ }, "requiredScopes": ["event: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\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\n\n\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='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "appId": { + "type": "string", + "description": "The app identifier (e.g. 'com.emergetools.hackernews'). Required." + }, + "branch": { + "anyOf": [ + { + "type": "string", + "description": "Filter by git branch (e.g. 'main'). Omit to use the app's default branch." + }, + { + "type": "null" + } + ], + "description": "Filter by git branch (e.g. 'main'). Omit to use the app's default branch.", + "default": null + }, + "project": { + "anyOf": [ + { + "type": "string", + "description": "Project ID for scoping. Recommended if app_id is not unique across projects." + }, + { + "type": "null" + } + ], + "description": "Project ID for scoping. Recommended if app_id is not unique across projects.", + "default": null + }, + "regionUrl": { + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ], + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", + "default": null + } + }, + "required": ["organizationSlug", "appId"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["project:read"] + }, { "name": "get_profile_details", "description": "Inspect a specific Sentry profile in detail.\n\nUSE THIS TOOL WHEN:\n- User shares a transaction profile URL and wants the details\n- User has a profile ID and wants a concise summary plus raw sample structure\n- User needs to inspect a continuous profile session by profiler ID and time range\n\nRETURNS:\n- Transaction profile summary with profile URL, transaction, trace, release, and runtime details\n- Sample structure summaries such as frame count, sample count, stacks, and thread breakdown\n- Top frames by occurrence for a quick hotspot overview\n\nNOTE: This tool supports two profile modes.\n- Transaction profiles: pass `profileUrl` or `organizationSlug` + `projectSlugOrId` + `profileId`\n- Continuous profiles: pass `profileUrl` or `organizationSlug` + `projectSlugOrId` + `profilerId` + `start` + `end`\n\n\n### Transaction profile URL\n```\nget_profile_details(\n profileUrl='https://my-org.sentry.io/explore/profiling/profile/backend/cfe78a5c892d4a64a962d837673398d2/flamegraph/'\n)\n```\n\n### Transaction profile by ID\n```\nget_profile_details(\n organizationSlug='my-org',\n projectSlugOrId='backend',\n profileId='cfe78a5c892d4a64a962d837673398d2'\n)\n```\n\n### Continuous profile by session\n```\nget_profile_details(\n organizationSlug='my-org',\n projectSlugOrId='backend',\n profilerId='041bde57b9844e36b8b7e5734efae5f7',\n start='2024-01-01T00:00:00Z',\n end='2024-01-01T01:00:00Z'\n)\n```\n", @@ -575,7 +635,7 @@ }, { "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 `:`.\n\n\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", + "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 `:`.\n\n\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", "inputSchema": { "type": "object", "properties": { @@ -601,7 +661,7 @@ "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, - "requiredScopes": ["event:read"] + "requiredScopes": ["event:read", "project:read"] }, { "name": "search_docs", diff --git a/packages/mcp-core/src/tools/get-event-attachment.ts b/packages/mcp-core/src/tools/get-event-attachment.ts index 8fcad7f27..fa83a35b9 100644 --- a/packages/mcp-core/src/tools/get-event-attachment.ts +++ b/packages/mcp-core/src/tools/get-event-attachment.ts @@ -6,6 +6,7 @@ import type { ImageContent, EmbeddedResource, } from "@modelcontextprotocol/sdk/types.js"; +import { blobToBase64 } from "../internal/blob-utils"; import { ParamOrganizationSlug, ParamProjectSlug, @@ -81,18 +82,12 @@ export default defineTool({ if (isBinary) { const isImage = attachment.attachment.mimetype?.startsWith("image/"); - // Base64 encode the binary attachment content - // and add to the content as an embedded resource - const uint8Array = new Uint8Array(await attachment.blob.arrayBuffer()); - let binary = ""; - for (let i = 0; i < uint8Array.byteLength; i++) { - binary += String.fromCharCode(uint8Array[i]); - } + const base64 = await blobToBase64(attachment.blob); if (isImage) { const image: ImageContent = { type: "image", mimeType: attachment.attachment.mimetype, - data: btoa(binary), + data: base64, }; contentParts.push(image); } else { @@ -101,7 +96,7 @@ export default defineTool({ resource: { uri: `file://${attachment.filename}`, mimeType: attachment.attachment.mimetype, - blob: btoa(binary), + blob: base64, }, }; contentParts.push(resource); diff --git a/packages/mcp-core/src/tools/get-latest-base-snapshot.test.ts b/packages/mcp-core/src/tools/get-latest-base-snapshot.test.ts new file mode 100644 index 000000000..f9409a55b --- /dev/null +++ b/packages/mcp-core/src/tools/get-latest-base-snapshot.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest"; +import { http, HttpResponse } from "msw"; +import { mswServer } from "@sentry/mcp-server-mocks"; +import getLatestBaseSnapshot from "./get-latest-base-snapshot.js"; +import { getServerContext } from "../test-setup.js"; + +const latestBaseFixture = { + id: "232800", + project_id: "12345", + app_info: { + app_id: "com.emergetools.hackernews", + name: "HackerNews", + platform: "ios", + }, + vcs_info: { + head_sha: "abc123def456", + head_ref: "main", + }, + images: [ + { + display_name: "Home Screen", + group: "Main", + image_file_name: "snapshots-iphone/main_home_screen.png", + description: "Home screen view", + }, + { + display_name: "Settings", + group: "Settings", + image_file_name: "snapshots-iphone/settings_page.png", + description: "Settings page", + }, + ], +}; + +describe("get_latest_base_snapshot", () => { + it("returns curated markdown from latest base build", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/latest-base/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("app_id")).toBe( + "com.emergetools.hackernews", + ); + expect(url.searchParams.get("compact_metadata")).toBe("true"); + return HttpResponse.json(latestBaseFixture); + }, + { once: true }, + ), + ); + + const result = await getLatestBaseSnapshot.handler( + { + organizationSlug: "sentry", + appId: "com.emergetools.hackernews", + branch: null, + project: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("# Latest Base Snapshot"); + expect(text).toContain("com.emergetools.hackernews"); + expect(text).toContain("**Snapshot ID**: 232800"); + expect(text).toContain("**Total Images**: 2"); + expect(text).toContain("`Home Screen` (Main)"); + expect(text).toContain("`Settings` (Settings)"); + expect(text).toContain("**App Name**: HackerNews"); + expect(text).toContain("**Platform**: ios"); + expect(text).toContain("**Branch**: main (`abc123de`)"); + expect(text).toContain("get_sentry_resource"); + expect(text).toMatchInlineSnapshot(` + "# Latest Base Snapshot for **com.emergetools.hackernews** in **sentry** + + ## Summary + + - **URL**: https://sentry.sentry.io/preprod/snapshots/232800/ + - **Snapshot ID**: 232800 + - **App Name**: HackerNews + - **Platform**: ios + - **Branch**: main (\`abc123de\`) + - **Total Images**: 2 + + ## Images + + - \`Home Screen\` (Main) — file: \`snapshots-iphone/main_home_screen.png\` + - \`Settings\` (Settings) — file: \`snapshots-iphone/settings_page.png\` + + ## Next Steps + + - To view a specific image, use \`get_sentry_resource(url="https://sentry.sentry.io/preprod/snapshots/232800/?selectedSnapshot=")\`" + `); + }); + + it("passes branch filter to endpoint", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/latest-base/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("branch")).toBe("main"); + return HttpResponse.json(latestBaseFixture); + }, + { once: true }, + ), + ); + + const result = await getLatestBaseSnapshot.handler( + { + organizationSlug: "sentry", + appId: "com.emergetools.hackernews", + branch: "main", + project: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("**Snapshot ID**: 232800"); + }); + + it("handles empty images array", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/latest-base/", + () => + HttpResponse.json({ + id: "232801", + images: [], + app_info: { app_id: "com.test.app", name: "Test" }, + }), + { once: true }, + ), + ); + + const result = await getLatestBaseSnapshot.handler( + { + organizationSlug: "sentry", + appId: "com.test.app", + branch: null, + project: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("**Total Images**: 0"); + expect(text).not.toContain("## Images"); + }); +}); diff --git a/packages/mcp-core/src/tools/get-latest-base-snapshot.ts b/packages/mcp-core/src/tools/get-latest-base-snapshot.ts new file mode 100644 index 000000000..766ef790b --- /dev/null +++ b/packages/mcp-core/src/tools/get-latest-base-snapshot.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../internal/tool-helpers/api"; +import type { ServerContext } from "../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; + +export default defineTool({ + name: "get_latest_base_snapshot", + skills: ["preprod"], + requiredScopes: ["project:read"], + description: [ + "Get the most recent base-build snapshot for a given app, returning a compact list of all images.", + "", + "Use this tool when you need to:", + "- Find what the current UI looks like for an app (latest screenshots from the main/default branch)", + "- Look up a specific screen or component from the most recent baseline build", + "- Browse available screenshots before requesting specific images", + "", + "Base builds are snapshots on the default branch (no comparison). This endpoint returns", + "compact image metadata (display_name, image_file_name, group, description) for every image.", + "", + "", + "### Get the latest base snapshot for an app", + "", + "```", + 'get_latest_base_snapshot(organizationSlug="sentry", appId="com.emergetools.hackernews")', + "```", + "", + "### Get the latest base snapshot for a specific branch", + "", + "```", + 'get_latest_base_snapshot(organizationSlug="sentry", appId="com.emergetools.hackernews", branch="main")', + "```", + "", + "", + "", + "- The response includes compact metadata per image. Scan the list to find images matching what you need.", + "- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').", + "- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + appId: z + .string() + .trim() + .describe( + "The app identifier (e.g. 'com.emergetools.hackernews'). Required.", + ), + branch: z + .string() + .trim() + .describe( + "Filter by git branch (e.g. 'main'). Omit to use the app's default branch.", + ) + .nullable() + .default(null), + project: z + .string() + .trim() + .describe( + "Project ID for scoping. Recommended if app_id is not unique across projects.", + ) + .nullable() + .default(null), + regionUrl: ParamRegionUrl.nullable().default(null), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + setTag("organization.slug", params.organizationSlug); + + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + + const data = (await apiService.getLatestBaseSnapshot({ + organizationSlug: params.organizationSlug, + appId: params.appId, + branch: params.branch ?? undefined, + project: params.project ?? undefined, + compactMetadata: true, + })) as Record; + + const snapshotId = data.id as string | undefined; + const images = (data.images as Array>) || []; + const appInfo = data.app_info as Record | undefined; + const vcsInfo = data.vcs_info as Record | undefined; + + const snapshotUrl = snapshotId + ? `https://${params.organizationSlug}.sentry.io/preprod/snapshots/${snapshotId}/` + : null; + + const sections: string[] = []; + + sections.push( + `# Latest Base Snapshot for **${params.appId}** in **${params.organizationSlug}**`, + ); + + sections.push("\n## Summary\n"); + if (snapshotUrl) sections.push(`- **URL**: ${snapshotUrl}`); + if (snapshotId) sections.push(`- **Snapshot ID**: ${snapshotId}`); + if (appInfo) { + if (appInfo.name) sections.push(`- **App Name**: ${appInfo.name}`); + if (appInfo.platform) + sections.push(`- **Platform**: ${appInfo.platform}`); + } + if (vcsInfo) { + if (vcsInfo.head_ref) + sections.push( + `- **Branch**: ${vcsInfo.head_ref} (\`${String(vcsInfo.head_sha ?? "").slice(0, 8)}\`)`, + ); + } + sections.push(`- **Total Images**: ${images.length}`); + + if (images.length > 0) { + sections.push("\n## Images\n"); + for (const img of images) { + const name = img.display_name || img.image_file_name || "unknown"; + const group = img.group ? ` (${img.group})` : ""; + const file = + img.image_file_name && img.image_file_name !== img.display_name + ? ` — file: \`${img.image_file_name}\`` + : ""; + sections.push(`- \`${name}\`${group}${file}`); + } + } + + sections.push( + snapshotUrl + ? `\n## Next Steps\n\n- To view a specific image, use \`get_sentry_resource(url="${snapshotUrl}?selectedSnapshot=")\`` + : "\n## Next Steps\n\n- To view a specific image, use `get_sentry_resource` with the snapshot URL + `?selectedSnapshot=`", + ); + + return sections.join("\n"); + }, +}); diff --git a/packages/mcp-core/src/tools/get-sentry-resource.ts b/packages/mcp-core/src/tools/get-sentry-resource.ts index f741edb86..20b6705a4 100644 --- a/packages/mcp-core/src/tools/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/get-sentry-resource.ts @@ -18,6 +18,7 @@ import getIssueDetails from "./get-issue-details"; import getTraceDetails from "./get-trace-details"; import getProfileDetails from "./get-profile-details"; import getReplayDetails from "./get-replay-details"; +import getSnapshotDetails from "./get-snapshot-details"; /** Types with full API integration. */ export const FULLY_SUPPORTED_TYPES = [ @@ -37,7 +38,8 @@ export type RecognizedType = "monitor" | "release"; export type ResolvedResourceType = | FullySupportedType | RecognizedType - | "profile"; + | "profile" + | "snapshot"; export interface ResolvedResourceParams { type: ResolvedResourceType; @@ -60,6 +62,9 @@ export interface ResolvedResourceParams { monitorSlug?: string; // Release params releaseVersion?: string; + // Snapshot params + snapshotId?: string; + selectedSnapshot?: string; } export function resolveResourceParams(params: { @@ -321,6 +326,17 @@ function resolveFromParsedUrl( organizationSlug, releaseVersion: parsed.releaseVersion, }; + + case "snapshot": + if (!parsed.snapshotId) { + throw new UserInputError("Could not extract snapshot ID from URL."); + } + return { + type: "snapshot", + organizationSlug, + snapshotId: parsed.snapshotId, + selectedSnapshot: parsed.selectedSnapshot, + }; } } @@ -401,13 +417,14 @@ function generateUnsupportedResourceMessage( export default defineTool({ name: "get_sentry_resource", - skills: ["inspect", "triage", "seer"], // Preserve legacy issue-detail access for triage and Seer workflows. - requiredScopes: ["event:read"], + skills: ["inspect", "triage", "seer", "preprod"], + requiredScopes: ["event:read", "project:read"], description: [ "Fetch a Sentry resource by URL or by type and ID.", "", - "Supports issues, events, traces, spans, replays, and breadcrumbs.", + "Supports issues, events, traces, spans, replays, breadcrumbs, and preprod snapshots.", + "Snapshot URLs with ?selectedSnapshot= return the specific image and full metadata.", "Trace lookups return a condensed overview by default.", "", "For `resourceType='span'`, pass `resourceId` as `:`.", @@ -427,6 +444,12 @@ export default defineTool({ "", "### Replay by ID", "get_sentry_resource(resourceType='replay', organizationSlug='my-org', resourceId='7e07485f-12f9-416b-8b14-26260799b51f')", + "", + "### Snapshot diff summary", + "get_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/')", + "", + "### View a specific snapshot image", + "get_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/241539/?selectedSnapshot=login_screen.png')", "", ].join("\n"), @@ -582,6 +605,18 @@ export default defineTool({ context, ); + case "snapshot": + return getSnapshotDetails.handler( + { + snapshotUrl: params.url ?? null, + organizationSlug: resolved.organizationSlug, + snapshotId: resolved.snapshotId ?? null, + selectedSnapshot: resolved.selectedSnapshot ?? null, + regionUrl: context.constraints.regionUrl ?? null, + }, + context, + ); + default: { const _exhaustiveCheck: never = resolved.type; throw new Error(`Unhandled resource type: ${_exhaustiveCheck}`); diff --git a/packages/mcp-core/src/tools/get-snapshot-details.test.ts b/packages/mcp-core/src/tools/get-snapshot-details.test.ts new file mode 100644 index 000000000..610260a8a --- /dev/null +++ b/packages/mcp-core/src/tools/get-snapshot-details.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect } from "vitest"; +import { http, HttpResponse } from "msw"; +import { mswServer } from "@sentry/mcp-server-mocks"; +import getSnapshotDetails from "./get-snapshot-details.js"; +import { getServerContext } from "../test-setup.js"; +import type { + ImageContent, + TextContent, +} from "@modelcontextprotocol/sdk/types.js"; + +const snapshotFixture = { + head_artifact_id: "231949", + base_artifact_id: "231900", + project_id: "12345", + comparison_type: "diff", + state: "visible", + vcs_info: { + head_sha: "abc123def", + base_sha: "000111222", + head_ref: "feature/new-login", + base_ref: "main", + pr_number: "789", + provider: "github", + repo_name: "getsentry/sentry", + }, + changed_count: 2, + added_count: 1, + removed_count: 0, + renamed_count: 1, + unchanged_count: 10, + errored_count: 0, + skipped_count: 0, + approval_info: { + status: "requires_approval", + is_auto_approved: false, + approvers: [], + }, + comparison_run_info: { + state: "SUCCESS", + completed_at: "2026-05-06T10:00:00Z", + duration_ms: 4500, + }, + images: [ + { + display_name: "login_screen.png", + group: "auth", + image_file_name: "snapshots-iphone-16/auth_login_screen.png", + description: "Auth login view", + }, + { + display_name: "dashboard.png", + group: "main", + image_file_name: "snapshots-iphone-16/main_dashboard.png", + description: "Main dashboard", + }, + { + display_name: "new_modal.png", + group: "dialogs", + image_file_name: "snapshots-iphone-16/dialogs_new_modal.png", + description: "New dialog modal", + }, + { + display_name: "settings_page.png", + group: "settings", + image_file_name: "snapshots-iphone-16/settings_page.png", + description: "Settings page", + }, + ], + changed: [ + { + head_image: { + display_name: "login_screen.png", + group: "auth", + image_file_name: "snapshots-iphone-16/auth_login_screen.png", + }, + base_image: { + display_name: "login_screen.png", + group: "auth", + image_file_name: "snapshots-iphone-16/auth_login_screen.png", + }, + diff: 0.125, + }, + { + head_image: { + display_name: "dashboard.png", + group: "main", + image_file_name: "snapshots-iphone-16/main_dashboard.png", + }, + base_image: { + display_name: "dashboard.png", + group: "main", + image_file_name: "snapshots-iphone-16/main_dashboard.png", + }, + diff: 0.021, + }, + ], + added: [ + { + display_name: "new_modal.png", + group: "dialogs", + image_file_name: "snapshots-iphone-16/dialogs_new_modal.png", + }, + ], + renamed: [ + { + head_image: { + display_name: "settings_page.png", + group: "settings", + image_file_name: "snapshots-iphone-16/settings_page.png", + }, + base_image: { + display_name: "preferences_page.png", + group: "settings", + image_file_name: "snapshots-iphone-16/preferences_page.png", + }, + diff: null, + }, + ], + removed: [], + errored: [], + unchanged: [], +}; + +const imageDetailFixture = { + display_name: "login_screen.png", + group: "auth", + image_file_name: "snapshots-iphone-16/auth_login_screen.png", + width: 1080, + height: 1920, + context: { + preview: { + container_display_name: "Auth Login", + display_name: "login_screen.png", + }, + simulator: { device_name: "iPhone 16" }, + }, + image_url: + "/api/0/organizations/sentry/preprodartifacts/snapshots/231949/images/auth_login_screen.png/download/", +}; + +const fakePng = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +function setupSnapshotMock() { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/231949/", + () => HttpResponse.json(snapshotFixture), + { once: true }, + ), + ); +} + +function setupImageMocks() { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/231949/images/login_screen.png/", + () => HttpResponse.json(imageDetailFixture), + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/231949/images/auth_login_screen.png/download/", + () => + new HttpResponse(fakePng, { + headers: { "content-type": "image/png" }, + }), + { once: true }, + ), + ); +} + +describe("get_snapshot_details", () => { + it("parses snapshot URL and returns curated summary", async () => { + setupSnapshotMock(); + const result = await getSnapshotDetails.handler( + { + snapshotUrl: "https://sentry.sentry.io/preprod/snapshots/231949/", + organizationSlug: null, + snapshotId: null, + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("# Snapshot 231949 in **sentry**"); + expect(text).toContain("**Type**: diff"); + expect(text).toContain("**State**: visible"); + expect(text).toContain("2 changed, 1 added, 0 removed, 1 renamed"); + expect(text).toContain("**Repo**: getsentry/sentry"); + expect(text).toContain("**Head**: feature/new-login (`abc123de`)"); + expect(text).toContain("**Base**: main (`00011122`)"); + expect(text).toContain("**PR**: #789"); + expect(text).toContain("**Approval**: requires_approval"); + expect(text).toContain( + "`login_screen.png` (auth) — file: `snapshots-iphone-16/auth_login_screen.png` — 12.5% diff", + ); + expect(text).toContain( + "`dashboard.png` (main) — file: `snapshots-iphone-16/main_dashboard.png` — 2.1% diff", + ); + expect(text).toContain("**Added:**"); + expect(text).toContain("`new_modal.png` (dialogs)"); + expect(text).toContain("**Renamed:**"); + expect(text).toContain("`preferences_page.png` → `settings_page.png`"); + expect(text).toContain("get_sentry_resource"); + expect(text).toMatchInlineSnapshot(` + "# Snapshot 231949 in **sentry** + + ## Summary + + - **URL**: https://sentry.sentry.io/preprod/snapshots/231949/ + - **Type**: diff + - **State**: visible + - **Project ID**: 12345 + - **Images**: 4 total (2 changed, 1 added, 0 removed, 1 renamed, 10 unchanged, 0 errored) + + ## VCS Info + + - **Repo**: getsentry/sentry + - **Head**: feature/new-login (\`abc123de\`) + - **Base**: main (\`00011122\`) + - **PR**: #789 + + - **Approval**: requires_approval + + ## Changes + + **Changed:** + - \`login_screen.png\` (auth) — file: \`snapshots-iphone-16/auth_login_screen.png\` — 12.5% diff + - \`dashboard.png\` (main) — file: \`snapshots-iphone-16/main_dashboard.png\` — 2.1% diff + + **Added:** + - \`new_modal.png\` (dialogs) — file: \`snapshots-iphone-16/dialogs_new_modal.png\` + + **Renamed:** + - \`preferences_page.png\` → \`settings_page.png\` + + ## All Images + + - \`login_screen.png\` (auth) — file: \`snapshots-iphone-16/auth_login_screen.png\` + - \`dashboard.png\` (main) — file: \`snapshots-iphone-16/main_dashboard.png\` + - \`new_modal.png\` (dialogs) — file: \`snapshots-iphone-16/dialogs_new_modal.png\` + - \`settings_page.png\` (settings) — file: \`snapshots-iphone-16/settings_page.png\` + + ## Next Steps + + - To view a specific image, use \`get_sentry_resource(url="https://sentry.sentry.io/preprod/snapshots/231949/?selectedSnapshot=")\`" + `); + }); + + it("works with explicit org slug and snapshot ID", async () => { + setupSnapshotMock(); + const result = await getSnapshotDetails.handler( + { + snapshotUrl: null, + organizationSlug: "sentry", + snapshotId: "231949", + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("**Type**: diff"); + expect(text).toContain("2 changed"); + }); + + it("throws on missing params", async () => { + await expect( + getSnapshotDetails.handler( + { + snapshotUrl: null, + organizationSlug: null, + snapshotId: null, + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ), + ).rejects.toThrow("Provide either snapshotUrl or both"); + }); + + it("throws on invalid URL", async () => { + await expect( + getSnapshotDetails.handler( + { + snapshotUrl: "https://example.com/not-a-snapshot", + organizationSlug: null, + snapshotId: null, + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ), + ).rejects.toThrow("Could not parse snapshot URL"); + }); + + it("handles 404 error", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/999999/", + () => HttpResponse.json({ detail: "Not found" }, { status: 404 }), + { once: true }, + ), + ); + await expect( + getSnapshotDetails.handler( + { + snapshotUrl: "https://sentry.sentry.io/preprod/snapshots/999999/", + organizationSlug: null, + snapshotId: null, + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ), + ).rejects.toThrow(); + }); + + it("handles solo comparison type with images array", async () => { + const soloFixture = { + head_artifact_id: "232703", + base_artifact_id: null, + project_id: "12345", + comparison_type: "solo", + state: "visible", + vcs_info: { + head_sha: "abc123", + base_sha: null, + head_ref: "main", + base_ref: null, + pr_number: null, + provider: "github", + repo_name: "EmergeTools/hackernews", + }, + images: [ + { + display_name: "Dark mode", + group: "Content View", + image_file_name: "snapshots-ipad/Content_View_Dark_mode.png", + description: "Dark mode content view", + }, + ], + changed: [], + added: [], + removed: [], + renamed: [], + errored: [], + unchanged: [], + changed_count: 0, + added_count: 0, + removed_count: 0, + renamed_count: 0, + unchanged_count: 0, + errored_count: 0, + skipped_count: 0, + }; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/232703/", + () => HttpResponse.json(soloFixture), + { once: true }, + ), + ); + const result = await getSnapshotDetails.handler( + { + snapshotUrl: null, + organizationSlug: "sentry", + snapshotId: "232703", + selectedSnapshot: null, + regionUrl: null, + }, + getServerContext(), + ); + const text = result as string; + expect(text).toContain("**Type**: solo"); + expect(text).toContain("`Dark mode` (Content View)"); + }); + + it("fetches image metadata and binary when selectedSnapshot is provided", async () => { + setupImageMocks(); + const result = await getSnapshotDetails.handler( + { + snapshotUrl: "https://sentry.sentry.io/preprod/snapshots/231949/", + organizationSlug: null, + snapshotId: null, + selectedSnapshot: "login_screen.png", + regionUrl: null, + }, + getServerContext(), + ); + const parts = result as (TextContent | ImageContent)[]; + const textParts = parts.filter((p): p is TextContent => p.type === "text"); + const imageParts = parts.filter( + (p): p is ImageContent => p.type === "image", + ); + expect(textParts[0]!.text).toContain("login_screen.png"); + expect(textParts[0]!.text).toContain("**Group**: auth"); + expect(textParts[0]!.text).toContain("1080×1920"); + expect(textParts[0]!.text).not.toContain("image_url"); + expect(imageParts.length).toBe(1); + expect(imageParts[0]!.mimeType).toBe("image/png"); + expect(textParts[0]!.text).toMatchInlineSnapshot(` + "## login_screen.png + + - **Display Name**: login_screen.png + - **Group**: auth + - **File**: \`snapshots-iphone-16/auth_login_screen.png\` + - **Dimensions**: 1080×1920 + - **Container**: Auth Login + - **Device**: iPhone 16" + `); + }); + + it("throws when selectedSnapshot image has no image_url", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry/preprodartifacts/snapshots/231949/images/missing.png/", + () => + HttpResponse.json({ + display_name: "missing.png", + group: null, + }), + { once: true }, + ), + ); + await expect( + getSnapshotDetails.handler( + { + snapshotUrl: "https://sentry.sentry.io/preprod/snapshots/231949/", + organizationSlug: null, + snapshotId: null, + selectedSnapshot: "missing.png", + regionUrl: null, + }, + getServerContext(), + ), + ).rejects.toThrow("No image_url returned"); + }); +}); diff --git a/packages/mcp-core/src/tools/get-snapshot-details.ts b/packages/mcp-core/src/tools/get-snapshot-details.ts new file mode 100644 index 000000000..06a187ae5 --- /dev/null +++ b/packages/mcp-core/src/tools/get-snapshot-details.ts @@ -0,0 +1,333 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import type { + TextContent, + ImageContent, +} from "@modelcontextprotocol/sdk/types.js"; +import { defineTool } from "../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../internal/tool-helpers/api"; +import type { ServerContext } from "../types"; +import { UserInputError } from "../errors"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; +import { resolveSnapshotParams } from "../internal/url-helpers"; +import { blobToBase64 } from "../internal/blob-utils"; + +interface SnapshotImageEntry { + display_name?: string | null; + group?: string | null; + image_file_name?: string; + [key: string]: unknown; +} + +interface SnapshotDiffPair { + base_image?: SnapshotImageEntry; + head_image?: SnapshotImageEntry; + diff?: number | null; +} + +function getImageDisplayName(img: SnapshotImageEntry): string { + return img.display_name || img.image_file_name || "unknown"; +} + +function formatImageLine(img: SnapshotImageEntry): string { + const name = getImageDisplayName(img); + const group = img.group ? ` (${img.group})` : ""; + const file = + img.image_file_name && img.image_file_name !== img.display_name + ? ` — file: \`${img.image_file_name}\`` + : ""; + return `- \`${name}\`${group}${file}`; +} + +export default defineTool({ + name: "get_snapshot_details", + internalOnly: true, + skills: ["preprod"], + requiredScopes: ["project:read"], + description: [ + "Get details of a preprod snapshot comparison, including image index and diff summary.", + "When selectedSnapshot is provided, fetches the actual image and full metadata for that image.", + "", + "Use this tool when you need to:", + "- Investigate a failed snapshot test from CI", + "- Browse what images exist in a snapshot build", + "- View a specific image from a snapshot (via selectedSnapshot param or ?selectedSnapshot= in URL)", + "", + "Returns compact image metadata (display_name, image_file_name, group, description) for all images.", + "To view a specific image, use get_sentry_resource with a snapshot URL containing ?selectedSnapshot=.", + "", + "", + "### Browse all images in a snapshot", + "", + "```", + 'get_snapshot_details(snapshotUrl="https://sentry.sentry.io/preprod/snapshots/231949/")', + "```", + "", + "### View a specific image", + "", + "```", + 'get_snapshot_details(snapshotUrl="https://sentry.sentry.io/preprod/snapshots/231949/?selectedSnapshot=login_screen.png")', + "```", + "", + "", + "", + "- Response includes compact metadata per image (display_name, image_file_name, group, description).", + "- To view an image, use get_sentry_resource with snapshot URL + ?selectedSnapshot=.", + "- The diff_percent field shows what percentage of pixels changed (0-100).", + "", + ].join("\n"), + inputSchema: { + snapshotUrl: z + .string() + .trim() + .describe( + "Full URL to the snapshot page, e.g. https://sentry.sentry.io/preprod/snapshots/231949/", + ) + .nullable() + .default(null), + organizationSlug: ParamOrganizationSlug.nullable().default(null), + snapshotId: z + .string() + .trim() + .describe("The numeric snapshot artifact ID.") + .nullable() + .default(null), + selectedSnapshot: z + .string() + .trim() + .describe( + "Image file name to fetch. When provided, returns the image binary + full metadata instead of the snapshot summary.", + ) + .nullable() + .default(null), + regionUrl: ParamRegionUrl.nullable().default(null), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const { organizationSlug, snapshotId } = resolveSnapshotParams(params); + + setTag("organization.slug", organizationSlug); + + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + + if (params.selectedSnapshot) { + return fetchSnapshotImage( + apiService, + organizationSlug, + snapshotId, + params.selectedSnapshot, + ); + } + + return fetchSnapshotSummary( + apiService, + organizationSlug, + snapshotId, + params.snapshotUrl, + ); + }, +}); + +async function fetchSnapshotImage( + apiService: ReturnType, + organizationSlug: string, + snapshotId: string, + imageName: string, +): Promise<(TextContent | ImageContent)[]> { + const imageDetail = (await apiService.getSnapshotImageDetail({ + organizationSlug, + snapshotId, + imageIdentifier: imageName, + })) as Record; + + const imageUrl = imageDetail.image_url as string | undefined; + if (!imageUrl) { + throw new UserInputError( + `No image_url returned for "${imageName}". The image may not exist in this snapshot.`, + ); + } + + const { image_url: _url, ...metadata } = imageDetail; + const lines: string[] = [`## ${imageName}\n`]; + if (metadata.display_name) + lines.push(`- **Display Name**: ${metadata.display_name}`); + if (metadata.group) lines.push(`- **Group**: ${metadata.group}`); + if (metadata.image_file_name) + lines.push(`- **File**: \`${metadata.image_file_name}\``); + if (metadata.width || metadata.height) + lines.push(`- **Dimensions**: ${metadata.width}×${metadata.height}`); + if (metadata.description) + lines.push(`- **Description**: ${metadata.description}`); + const context = metadata.context as Record | undefined; + if (context) { + const preview = context.preview as Record | undefined; + if (preview?.container_display_name) + lines.push(`- **Container**: ${preview.container_display_name}`); + const simulator = context.simulator as Record | undefined; + if (simulator?.device_name) + lines.push(`- **Device**: ${simulator.device_name}`); + const test = context.test_name as string | undefined; + if (test) lines.push(`- **Test**: ${test}`); + } + + const contentParts: (TextContent | ImageContent)[] = [ + { type: "text", text: lines.join("\n") }, + ]; + + const { blob, contentType } = await apiService.fetchImageByUrl(imageUrl); + if (!contentType.startsWith("image/")) { + contentParts.push({ + type: "text", + text: `(Unexpected content type: ${contentType})`, + }); + } else { + contentParts.push({ + type: "image", + data: await blobToBase64(blob), + mimeType: contentType, + }); + } + + return contentParts; +} + +async function fetchSnapshotSummary( + apiService: ReturnType, + organizationSlug: string, + snapshotId: string, + snapshotUrl: string | null, +): Promise { + const data = (await apiService.getSnapshotDetails({ + organizationSlug, + snapshotId, + compactMetadata: true, + })) as Record; + + const vcsInfo = data.vcs_info as Record | undefined; + const approvalInfo = data.approval_info as + | Record + | undefined; + + const allImages = (data.images as SnapshotImageEntry[]) || []; + const changed = (data.changed as SnapshotDiffPair[]) || []; + const renamed = (data.renamed as SnapshotDiffPair[]) || []; + const added = (data.added as SnapshotImageEntry[]) || []; + const removed = (data.removed as SnapshotImageEntry[]) || []; + const errored = (data.errored as SnapshotDiffPair[]) || []; + + const resolvedSnapshotUrl = + snapshotUrl || + `https://${organizationSlug}.sentry.io/preprod/snapshots/${snapshotId}/`; + + const sections: string[] = []; + + sections.push(`# Snapshot ${snapshotId} in **${organizationSlug}**`); + + sections.push("\n## Summary\n"); + sections.push(`- **URL**: ${resolvedSnapshotUrl}`); + sections.push(`- **Type**: ${data.comparison_type ?? "unknown"}`); + sections.push(`- **State**: ${data.state ?? "unknown"}`); + if (data.project_id) { + sections.push(`- **Project ID**: ${data.project_id}`); + } + + const changedCount = (data.changed_count as number) ?? changed.length; + const addedCount = (data.added_count as number) ?? added.length; + const removedCount = (data.removed_count as number) ?? removed.length; + const renamedCount = (data.renamed_count as number) ?? renamed.length; + const unchangedCount = (data.unchanged_count as number) ?? 0; + const erroredCount = (data.errored_count as number) ?? errored.length; + sections.push( + `- **Images**: ${allImages.length} total (${changedCount} changed, ${addedCount} added, ${removedCount} removed, ${renamedCount} renamed, ${unchangedCount} unchanged, ${erroredCount} errored)`, + ); + + if (vcsInfo) { + sections.push("\n## VCS Info\n"); + if (vcsInfo.repo_name) sections.push(`- **Repo**: ${vcsInfo.repo_name}`); + if (vcsInfo.head_ref) + sections.push( + `- **Head**: ${vcsInfo.head_ref} (\`${String(vcsInfo.head_sha ?? "").slice(0, 8)}\`)`, + ); + if (vcsInfo.base_ref) + sections.push( + `- **Base**: ${vcsInfo.base_ref} (\`${String(vcsInfo.base_sha ?? "").slice(0, 8)}\`)`, + ); + if (vcsInfo.pr_number) sections.push(`- **PR**: #${vcsInfo.pr_number}`); + } + + if (approvalInfo) { + const status = approvalInfo.status ?? "unknown"; + const auto = approvalInfo.is_auto_approved ? " (auto-approved)" : ""; + sections.push(`\n- **Approval**: ${status}${auto}`); + } + + const hasDiffs = + changed.length > 0 || + renamed.length > 0 || + added.length > 0 || + removed.length > 0 || + errored.length > 0; + + if (hasDiffs) { + sections.push("\n## Changes\n"); + + if (changed.length > 0) { + sections.push("**Changed:**"); + for (const pair of changed) { + const img = pair.head_image ?? {}; + const name = getImageDisplayName(img); + const group = img.group ? ` (${img.group})` : ""; + const file = + img.image_file_name && img.image_file_name !== img.display_name + ? ` — file: \`${img.image_file_name}\`` + : ""; + const diff = + pair.diff != null + ? ` — ${Math.round(pair.diff * 10000) / 100}% diff` + : ""; + sections.push(`- \`${name}\`${group}${file}${diff}`); + } + } + + if (added.length > 0) { + sections.push("\n**Added:**"); + for (const img of added) sections.push(formatImageLine(img)); + } + + if (removed.length > 0) { + sections.push("\n**Removed:**"); + for (const img of removed) sections.push(formatImageLine(img)); + } + + if (renamed.length > 0) { + sections.push("\n**Renamed:**"); + for (const pair of renamed) { + const newName = getImageDisplayName(pair.head_image ?? {}); + const oldName = getImageDisplayName(pair.base_image ?? {}); + sections.push(`- \`${oldName}\` → \`${newName}\``); + } + } + + if (errored.length > 0) { + sections.push("\n**Errored:**"); + for (const pair of errored) + sections.push(formatImageLine(pair.head_image ?? {})); + } + } + + if (allImages.length > 0) { + sections.push("\n## All Images\n"); + for (const img of allImages) sections.push(formatImageLine(img)); + } + + sections.push( + `\n## Next Steps\n\n- To view a specific image, use \`get_sentry_resource(url="${resolvedSnapshotUrl}?selectedSnapshot=")\``, + ); + + return sections.join("\n"); +} diff --git a/packages/mcp-core/src/tools/index.ts b/packages/mcp-core/src/tools/index.ts index 54492024c..ccd7347cf 100644 --- a/packages/mcp-core/src/tools/index.ts +++ b/packages/mcp-core/src/tools/index.ts @@ -23,6 +23,8 @@ import searchIssueEvents from "./search-issue-events"; import useSentry from "./use-sentry"; import getProfileDetails from "./get-profile-details"; import getSentryResource from "./get-sentry-resource"; +import getSnapshotDetails from "./get-snapshot-details"; +import getLatestBaseSnapshot from "./get-latest-base-snapshot"; // Default export: object mapping tool names to tools export default { @@ -53,6 +55,8 @@ export default { use_sentry: useSentry, get_profile_details: getProfileDetails, get_sentry_resource: getSentryResource, + get_snapshot_details: getSnapshotDetails, + get_latest_base_snapshot: getLatestBaseSnapshot, } as const; // Type export diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/mcp-server/src/cli/resolve.test.ts index 7ddbf4a37..231ab301a 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/mcp-server/src/cli/resolve.test.ts @@ -174,12 +174,13 @@ describe("cli/finalize", () => { accessToken: "tok", unknownArgs: [], }); - expect(cfg.finalSkills.size).toBe(5); // All skills: inspect, triage, project-management, seer, docs + expect(cfg.finalSkills.size).toBe(6); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); expect(cfg.finalSkills.has("project-management")).toBe(true); expect(cfg.finalSkills.has("seer")).toBe(true); expect(cfg.finalSkills.has("docs")).toBe(true); + expect(cfg.finalSkills.has("preprod")).toBe(true); }); // --disable-skills tests @@ -190,11 +191,12 @@ describe("cli/finalize", () => { unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); - expect(cfg.finalSkills.size).toBe(4); + expect(cfg.finalSkills.size).toBe(5); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); expect(cfg.finalSkills.has("project-management")).toBe(true); expect(cfg.finalSkills.has("docs")).toBe(true); + expect(cfg.finalSkills.has("preprod")).toBe(true); }); it("removes disabled skills when combined with --skills", () => { @@ -239,7 +241,7 @@ describe("cli/finalize", () => { }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.has("docs")).toBe(false); - expect(cfg.finalSkills.size).toBe(3); + expect(cfg.finalSkills.size).toBe(4); }); it("silently ignores disabling a skill not in the active set", () => { diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index 64df71efd..67ea1c74c 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -19,6 +19,7 @@ allowedTools: - get_doc - get_event_attachment - get_issue_tag_values + - get_latest_base_snapshot - get_profile_details - get_replay_details - get_sentry_resource diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index 64df71efd..67ea1c74c 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -19,6 +19,7 @@ allowedTools: - get_doc - get_event_attachment - get_issue_tag_values + - get_latest_base_snapshot - get_profile_details - get_replay_details - get_sentry_resource