diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 2d997943..d8449da7 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { getContinuousProfileUrl as getContinuousProfileUrlUtil, getAIConversationUrl as getAIConversationUrlUtil, + getDashboardUrl as getDashboardUrlUtil, getIssueUrl as getIssueUrlUtil, getMonitorUrl as getMonitorUrlUtil, getPreprodSnapshotUrl as getPreprodSnapshotUrlUtil, @@ -13,6 +14,7 @@ import { getTraceMetricsExploreUrl, getTraceUrl as getTraceUrlUtil, isSentryHost, + type DashboardUrlOptions, type TraceMetricIdentifier, } from "../utils/url-utils"; import { isNumericId } from "../utils/slug-validation"; @@ -57,6 +59,8 @@ import { ClientKeyListSchema, AutofixRunSchema, AutofixRunStateSchema, + DashboardListSchema, + DashboardSchema, TraceMetaSchema, TraceSchema, UserSchema, @@ -79,6 +83,8 @@ import type { AutofixRunState, ClientKey, ClientKeyList, + Dashboard, + DashboardListItem, Event, EventAttachment, EventAttachmentList, @@ -882,6 +888,20 @@ export class SentryApiService { ); } + getDashboardUrl( + organizationSlug: string, + dashboardId: string, + options: DashboardUrlOptions = {}, + ): string { + return getDashboardUrlUtil( + this.host, + organizationSlug, + dashboardId, + options, + this.protocol, + ); + } + // ================================================================================ // URL BUILDERS FOR DIFFERENT SENTRY APIS // ================================================================================ @@ -1450,6 +1470,65 @@ export class SentryApiService { return ProjectListSchema.parse(body); } + async listDashboards( + { + organizationSlug, + query, + sortBy, + limit = 10, + cursor, + }: { + organizationSlug: string; + query?: string | null; + sortBy?: "title" | "-title" | "dateCreated" | "-dateCreated"; + limit?: number; + cursor?: string | null; + }, + opts?: RequestOptions, + ): Promise<{ dashboards: DashboardListItem[]; nextCursor: string | null }> { + const queryParams = new URLSearchParams(); + queryParams.set("per_page", String(limit)); + if (query) { + queryParams.set("query", query); + } + if (sortBy) { + queryParams.set("sort", sortBy); + } + if (cursor) { + queryParams.set("cursor", cursor); + } + + const response = await this.request( + `/organizations/${organizationSlug}/dashboards/?${queryParams.toString()}`, + undefined, + opts, + ); + const body = await this.parseJsonResponse(response); + + return { + dashboards: DashboardListSchema.parse(body), + nextCursor: getNextCursor(response.headers.get("link")), + }; + } + + async getDashboard( + { + organizationSlug, + dashboardId, + }: { + organizationSlug: string; + dashboardId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/dashboards/${dashboardId}/`, + undefined, + opts, + ); + return DashboardSchema.parse(body); + } + /** * Gets a single project by slug or ID. * diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 2d8252aa..1b6e6e62 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -152,6 +152,112 @@ export const ProjectRepoLinkSchema = z }) .passthrough(); +/** + * Dashboard schemas validated against getsentry/sentry: + * - `src/sentry/dashboards/endpoints/organization_dashboards.py` + * - `src/sentry/dashboards/endpoints/organization_dashboard_details.py` + * - `src/sentry/api/serializers/models/dashboard.py` + */ +export const DashboardPermissionsSchema = z + .object({ + isEditableByEveryone: z.boolean().optional(), + teamsWithEditAccess: z.array(z.union([z.string(), z.number()])).optional(), + }) + .passthrough(); + +export const DashboardWidgetPreviewSchema = z + .object({ + displayType: z.string(), + layout: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .passthrough(); + +export const DashboardWidgetQuerySchema = z + .object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + fields: z.array(z.string()), + aggregates: z.array(z.string()), + columns: z.array(z.string()), + fieldAliases: z.array(z.string()), + conditions: z.string(), + orderby: z.string(), + widgetId: z.union([z.string(), z.number()]), + isHidden: z.boolean().optional(), + selectedAggregate: z.number().nullable().optional(), + linkedDashboards: z + .array( + z + .object({ + field: z.string(), + dashboardId: z.union([z.string(), z.number()]), + }) + .passthrough(), + ) + .optional(), + }) + .passthrough(); + +export const DashboardWidgetSchema = z + .object({ + id: z.union([z.string(), z.number()]), + title: z.string(), + description: z.string().nullable().optional(), + displayType: z.string(), + interval: z.string().nullable().optional(), + dateCreated: z.string(), + dashboardId: z.union([z.string(), z.number()]), + queries: z.array(DashboardWidgetQuerySchema).default([]), + limit: z.number().nullable().optional(), + widgetType: z.string().nullable().optional(), + layout: z.record(z.string(), z.unknown()).nullable().optional(), + datasetSource: z.string().optional(), + }) + .passthrough(); + +export const DashboardFiltersSchema = z.record(z.string(), z.unknown()); + +export const DashboardListItemSchema = z + .object({ + id: z.union([z.string(), z.number()]), + title: z.string(), + dateCreated: z.string(), + createdBy: z.unknown().nullable().optional(), + environment: z.array(z.string()).default([]), + filters: DashboardFiltersSchema.default({}), + lastVisited: z.string().nullable().optional(), + widgetDisplay: z.array(z.string()).default([]), + widgetPreview: z.array(DashboardWidgetPreviewSchema).default([]), + permissions: DashboardPermissionsSchema.nullable().optional(), + isFavorited: z.boolean().optional(), + projects: z.array(z.number()).default([]), + prebuiltId: z.union([z.string(), z.number()]).nullable().optional(), + }) + .passthrough(); + +export const DashboardListSchema = z.array(DashboardListItemSchema); + +export const DashboardSchema = z + .object({ + id: z.union([z.string(), z.number()]), + title: z.string(), + dateCreated: z.string(), + createdBy: z.unknown().nullable().optional(), + widgets: z.array(DashboardWidgetSchema), + projects: z.array(z.number()).default([]), + environment: z.array(z.string()).default([]), + filters: DashboardFiltersSchema.default({}), + permissions: DashboardPermissionsSchema.nullable().optional(), + isFavorited: z.boolean().optional(), + prebuiltId: z.union([z.string(), z.number()]).nullable().optional(), + period: z.string().nullable().optional(), + start: z.string().nullable().optional(), + end: z.string().nullable().optional(), + utc: z.union([z.string(), z.boolean()]).nullable().optional(), + expired: z.boolean().optional(), + }) + .passthrough(); + const ReplayTagsSchema = z.preprocess( (value) => { if (value === undefined || value === null || Array.isArray(value)) { diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index c19dfb28..b2fc0e78 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -49,6 +49,9 @@ import type { ClientKeySchema, CommitListSchema, CommitSchema, + DashboardListItemSchema, + DashboardSchema, + DashboardWidgetSchema, DefaultEventSchema, DeployListSchema, DeploySchema, @@ -167,6 +170,11 @@ export type EventAttachmentList = z.infer; export type TagList = z.infer; export type ClientKeyList = z.infer; +// Dashboard types +export type Dashboard = z.infer; +export type DashboardListItem = z.infer; +export type DashboardWidget = z.infer; + // Trace types export type TraceMeta = z.infer; export type TraceSpan = z.infer; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index e7a385e3..47751e8f 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,8 +5,13 @@ "description": "Read-only access to core Sentry data: issues, events, traces, replays, releases, monitors, profiles, documentation, and project metadata", "defaultEnabled": true, "order": 1, - "toolCount": 26, + "toolCount": 28, "tools": [ + { + "name": "find_dashboards", + "description": "Find Sentry dashboards in an organization.\n\nUse this tool when you need to:\n- List dashboards in an organization\n- Find a dashboard ID before calling get_dashboard_details\n- Search dashboards by title\n\n\nfind_dashboards(organizationSlug='my-organization')\nfind_dashboards(organizationSlug='my-organization', titleQuery='errors')\n\n\n\n- Dashboard IDs are organization-scoped.\n- Use `get_dashboard_details` after finding the correct dashboard ID.\n", + "requiredScopes": ["org:read"] + }, { "name": "find_monitors", "description": "Find Sentry cron monitors.\n\nUse this tool when you need to:\n- List cron monitors in an organization\n- Find a monitor by name or slug before getting details\n- Check monitor status, owner, project, schedule, or recent environment state\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", @@ -37,6 +42,11 @@ "description": "Fetch all spans for an AI conversation by its gen_ai.conversation.id.\n\nA conversation is a set of spans sharing the same gen_ai.conversation.id. To discover conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'.", "requiredScopes": ["event:read", "project:read"] }, + { + "name": "get_dashboard_details", + "description": "Get detailed information about a specific Sentry dashboard.\n\nUse this tool when you need to:\n- Inspect a dashboard's widgets and saved query definitions\n- View dashboard projects, environments, filters, layout, and widget IDs\n- Resolve a dashboard by exact title or numeric ID\n\n\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\n- Numeric dashboard IDs are resolved directly.\n- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.\n- This returns saved widget query definitions, not live widget data.\n", + "requiredScopes": ["org:read"] + }, { "name": "get_doc", "description": "Fetch the full markdown content of a Sentry documentation page.\n\nUse this tool when you need to:\n- Read the complete documentation for a specific topic\n- Get detailed implementation examples or code snippets\n- Access the full context of a documentation page\n- Extract specific sections from documentation\n\n\n### Get the Next.js integration guide\n\n```\nget_doc(path='/platforms/javascript/guides/nextjs.md')\n```\n\n\n\n- Use the path from search_docs results for accurate fetching\n- Paths should end with .md extension\n", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index cb932194..147a73b7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -257,6 +257,77 @@ "skills": ["inspect", "seer", "docs", "triage", "project-management"], "surface": "direct" }, + { + "name": "find_dashboards", + "description": "Find Sentry dashboards in an organization.\n\nUse this tool when you need to:\n- List dashboards in an organization\n- Find a dashboard ID before calling get_dashboard_details\n- Search dashboards by title\n\n\nfind_dashboards(organizationSlug='my-organization')\nfind_dashboards(organizationSlug='my-organization', titleQuery='errors')\n\n\n\n- Dashboard IDs are organization-scoped.\n- Use `get_dashboard_details` after finding the correct dashboard ID.\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." + }, + "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 + }, + "titleQuery": { + "anyOf": [ + { + "type": "string", + "description": "Optional title substring to search for." + }, + { + "type": "null" + } + ], + "description": "Optional title substring to search for.", + "default": null + }, + "sort": { + "type": "string", + "enum": ["title", "-title", "dateCreated", "-dateCreated"], + "description": "Sort order for dashboard results.", + "default": "title" + }, + "cursor": { + "anyOf": [ + { + "type": "string", + "description": "Optional pagination cursor from a previous response. Reuse cursors only with the same search scope and project constraint." + }, + { + "type": "null" + } + ], + "description": "Optional pagination cursor from a previous response. Reuse cursors only with the same search scope and project constraint.", + "default": null + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Maximum number of dashboards to return.", + "default": 10 + } + }, + "required": ["organizationSlug"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["org:read"], + "skills": ["inspect"], + "surface": "catalog" + }, { "name": "find_dsns", "description": "List all Sentry DSNs for a specific project.\n\nUse this tool when you need to:\n- Retrieve a SENTRY_DSN for a specific project\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.\n", @@ -590,6 +661,43 @@ "skills": ["inspect", "triage", "seer"], "surface": "catalog" }, + { + "name": "get_dashboard_details", + "description": "Get detailed information about a specific Sentry dashboard.\n\nUse this tool when you need to:\n- Inspect a dashboard's widgets and saved query definitions\n- View dashboard projects, environments, filters, layout, and widget IDs\n- Resolve a dashboard by exact title or numeric ID\n\n\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')\nget_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')\n\n\n\n- Numeric dashboard IDs are resolved directly.\n- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.\n- This returns saved widget query definitions, not live widget data.\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." + }, + "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 + }, + "dashboardIdOrTitle": { + "type": "string", + "minLength": 1, + "description": "The dashboard's numeric ID or exact title." + } + }, + "required": ["organizationSlug", "dashboardIdOrTitle"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["org:read"], + "skills": ["inspect"], + "surface": "catalog" + }, { "name": "get_doc", "description": "Fetch the full markdown content of a Sentry documentation page.\n\nUse this tool when you need to:\n- Read the complete documentation for a specific topic\n- Get detailed implementation examples or code snippets\n- Access the full context of a documentation page\n- Extract specific sections from documentation\n\n\n### Get the Next.js integration guide\n\n```\nget_doc(path='/platforms/javascript/guides/nextjs.md')\n```\n\n\n\n- Use the path from search_docs results for accurate fetching\n- Paths should end with .md extension\n", diff --git a/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts b/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts new file mode 100644 index 00000000..2c10661a --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts @@ -0,0 +1,514 @@ +import { dashboardListFixture, mswServer } from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import findDashboards from "./find-dashboards.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +const projectConstrainedContext = { + ...context, + constraints: { + ...context.constraints, + projectSlug: "cloudflare-mcp", + }, +}; + +describe("find_dashboards", () => { + it("lists dashboards with pagination hints", async () => { + const result = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: null, + limit: 1, + }, + context, + ); + + expect(result).toMatchInlineSnapshot(` + "# Dashboards in **sentry-mcp-evals** + + ## Errors Overview + + **ID**: 101 + **Widgets**: 2 + **Widget Types**: line, table + **Projects**: 4509106749636608 + **Environments**: production + **Created By**: Jane Developer + **Created**: 2025-04-14T10:15:00.000Z + **Last Visited**: 2025-04-15T12:00:00.000Z + **Favorited**: yes + **URL**: [Open Dashboard](https://sentry-mcp-evals.sentry.io/dashboard/101/?project=4509106749636608) + + ## Response Notes + + - Use \`get_dashboard_details\` with the dashboard ID for widgets and query definitions. + - More dashboards are available. Pass \`cursor: "dashboard-cursor"\` to fetch the next page. + " + `); + }); + + it("shows an empty title search", async () => { + const result = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: "missing", + sort: "title", + cursor: null, + limit: 10, + }, + context, + ); + + expect(result).toMatchInlineSnapshot(` + "# Dashboards in **sentry-mcp-evals** + + **Title query:** "missing" + + No dashboards found matching "missing". + " + `); + }); + + it("filters explicit dashboard project IDs in project-constrained sessions", async () => { + let requestUrl: string | null = null; + const paths: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + requestUrl = request.url; + return HttpResponse.json(dashboardListFixture); + }, + ), + ); + + const result = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: null, + limit: 10, + }, + projectConstrainedContext, + ); + + if (!requestUrl) { + throw new Error("Expected dashboard list request to be captured."); + } + const searchParams = new URL(requestUrl).searchParams; + expect(searchParams.has("project")).toBe(false); + expect(paths).toEqual([ + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + "/api/0/organizations/sentry-mcp-evals/dashboards/", + ]); + expect(result).not.toContain("**ID**: 101"); + expect(result).toContain("## Errors Overview Copy"); + expect(result).toContain("**ID**: 102"); + expect(result).toContain( + "https://sentry-mcp-evals.sentry.io/dashboard/102/?project=4509109104082945", + ); + }); + + it("fetches additional pages to fill project-constrained dashboard results", async () => { + const requestUrls: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + () => + HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + requestUrls.push(request.url); + const cursor = new URL(request.url).searchParams.get("cursor"); + if (!cursor) { + return HttpResponse.json( + [ + { + ...dashboardListFixture[0], + id: "201", + title: "Other Project Errors", + projects: [1], + }, + { + ...dashboardListFixture[1], + id: "202", + title: "Other Project Metrics", + projects: [2], + }, + ], + { + headers: { + Link: '; rel="next"; results="true"; cursor="page-2"', + }, + }, + ); + } + + return HttpResponse.json( + [ + { + ...dashboardListFixture[0], + id: "203", + title: "Cloudflare Errors", + projects: [4509109104082945], + }, + { + ...dashboardListFixture[1], + id: "204", + title: "Cloudflare Metrics", + projects: [4509109104082945], + }, + ], + { + headers: { + Link: '; rel="next"; results="true"; cursor="page-3"', + }, + }, + ); + }, + ), + ); + + const result = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: null, + limit: 2, + }, + projectConstrainedContext, + ); + + expect(requestUrls).toHaveLength(2); + expect(new URL(requestUrls[0]!).searchParams.get("per_page")).toBe("2"); + expect(new URL(requestUrls[1]!).searchParams.get("cursor")).toBe("page-2"); + expect(new URL(requestUrls[1]!).searchParams.get("per_page")).toBe("2"); + expect(result).not.toContain("Other Project Errors"); + expect(result).not.toContain("Other Project Metrics"); + expect(result).toContain("## Cloudflare Errors"); + expect(result).toContain("## Cloudflare Metrics"); + expect(result).toContain( + 'More dashboards are available. Pass `cursor: "mcp-dashboard-project:', + ); + }); + + it("uses a project cursor when a filtered page has more visible dashboards than requested", async () => { + const requestUrls: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + () => + HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + requestUrls.push(request.url); + const cursor = new URL(request.url).searchParams.get("cursor"); + if (!cursor) { + return HttpResponse.json( + [ + { + ...dashboardListFixture[0], + id: "301", + title: "Cloudflare Errors", + projects: [4509109104082945], + }, + { + ...dashboardListFixture[1], + id: "302", + title: "Other Project Metrics", + projects: [2], + }, + ], + { + headers: { + Link: '; rel="next"; results="true"; cursor="page-2"', + }, + }, + ); + } + + if (cursor === "page-2") { + return HttpResponse.json( + [ + { + ...dashboardListFixture[0], + id: "303", + title: "Cloudflare Metrics", + projects: [4509109104082945], + }, + { + ...dashboardListFixture[1], + id: "304", + title: "Cloudflare Throughput", + projects: [4509109104082945], + }, + ], + { + headers: { + Link: '; rel="next"; results="true"; cursor="page-3"', + }, + }, + ); + } + + return HttpResponse.json([ + { + ...dashboardListFixture[0], + id: "305", + title: "Cloudflare Latency", + projects: [4509109104082945], + }, + ]); + }, + ), + ); + + const firstResult = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: null, + limit: 2, + }, + projectConstrainedContext, + ); + const nextCursor = /cursor: "([^"]+)"/.exec(firstResult)?.[1]; + + expect(nextCursor).toEqual( + expect.stringContaining("mcp-dashboard-project:"), + ); + expect(firstResult).toContain("## Cloudflare Errors"); + expect(firstResult).toContain("## Cloudflare Metrics"); + expect(firstResult).not.toContain("Cloudflare Throughput"); + + const secondResult = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: nextCursor!, + limit: 1, + }, + projectConstrainedContext, + ); + + expect(new URL(requestUrls[2]!).searchParams.get("cursor")).toBe("page-2"); + expect(new URL(requestUrls[2]!).searchParams.get("per_page")).toBe("2"); + expect(secondResult).toContain("## Cloudflare Throughput"); + expect(secondResult).not.toContain("Cloudflare Metrics"); + expect(secondResult).not.toContain("Cloudflare Latency"); + }); + + it("resumes a project cursor within the first API page", async () => { + let requestUrl: string | null = null; + const firstPageCursor = `mcp-dashboard-project:${Buffer.from( + JSON.stringify({ + v: 1, + apiCursor: null, + offset: 1, + pageLimit: 2, + }), + ).toString("base64url")}`; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + () => + HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json([ + { + ...dashboardListFixture[0], + id: "401", + title: "Cloudflare Errors", + projects: [4509109104082945], + }, + { + ...dashboardListFixture[1], + id: "402", + title: "Cloudflare Metrics", + projects: [4509109104082945], + }, + ]); + }, + ), + ); + + const result = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: firstPageCursor, + limit: 1, + }, + projectConstrainedContext, + ); + + if (!requestUrl) { + throw new Error("Expected dashboard list request to be captured."); + } + const searchParams = new URL(requestUrl).searchParams; + expect(searchParams.has("cursor")).toBe(false); + expect(searchParams.get("per_page")).toBe("2"); + expect(result).not.toContain("Cloudflare Errors"); + expect(result).toContain("## Cloudflare Metrics"); + }); + + it("rejects project cursors in org-wide searches before calling Sentry", async () => { + let rejectPhase = false; + let orgWideDashboardRequests = 0; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + () => + HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + () => { + if (rejectPhase) { + orgWideDashboardRequests++; + } + return HttpResponse.json([ + { + ...dashboardListFixture[0], + id: "501", + title: "Cloudflare Errors", + projects: [4509109104082945], + }, + { + ...dashboardListFixture[1], + id: "502", + title: "Cloudflare Metrics", + projects: [4509109104082945], + }, + ]); + }, + ), + ); + + const projectResult = await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: null, + limit: 1, + }, + projectConstrainedContext, + ); + const projectCursor = /cursor: "([^"]+)"/.exec(projectResult)?.[1]; + expect(projectCursor).toEqual( + expect.stringContaining("mcp-dashboard-project:"), + ); + if (!projectCursor) { + throw new Error("Expected project-scoped dashboard cursor."); + } + + rejectPhase = true; + await expect( + findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: null, + sort: "title", + cursor: projectCursor, + limit: 1, + }, + context, + ), + ).rejects.toThrow("Project-scoped dashboard cursors"); + expect(orgWideDashboardRequests).toBe(0); + }); + + it("passes search, sort, cursor, and limit query parameters", async () => { + let requestUrl: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json(dashboardListFixture); + }, + ), + ); + + await findDashboards.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + titleQuery: "errors", + sort: "-dateCreated", + cursor: "dashboard-cursor", + limit: 25, + }, + context, + ); + + if (!requestUrl) { + throw new Error("Expected dashboard list request to be captured."); + } + const searchParams = new URL(requestUrl).searchParams; + expect(searchParams.get("query")).toBe("errors"); + expect(searchParams.get("sort")).toBe("-dateCreated"); + expect(searchParams.get("cursor")).toBe("dashboard-cursor"); + expect(searchParams.get("per_page")).toBe("25"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/find-dashboards.ts b/packages/mcp-core/src/tools/catalog/find-dashboards.ts new file mode 100644 index 00000000..d9ed1ea7 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.ts @@ -0,0 +1,312 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import type { DashboardListItem, SentryApiService } from "../../api-client"; +import { UserInputError } from "../../errors"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import type { ServerContext } from "../../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; +import { + filterDashboardsByProjectConstraint, + formatDashboardList, + resolveDashboardProjectConstraint, +} from "../support/dashboards"; + +const PROJECT_DASHBOARD_CURSOR_PREFIX = "mcp-dashboard-project:"; + +type ProjectDashboardCursor = { + v: 1; + apiCursor: string | null; + offset: number; + pageLimit: number; +}; + +function isProjectDashboardCursor( + value: unknown, +): value is ProjectDashboardCursor { + if (!value || typeof value !== "object") { + return false; + } + + const cursor = value as Record; + return ( + cursor.v === 1 && + (typeof cursor.apiCursor === "string" || cursor.apiCursor === null) && + typeof cursor.offset === "number" && + Number.isInteger(cursor.offset) && + cursor.offset >= 0 && + typeof cursor.pageLimit === "number" && + Number.isInteger(cursor.pageLimit) && + cursor.pageLimit > 0 && + cursor.pageLimit <= 100 + ); +} + +/** + * Encodes the project-scoped pagination state that Sentry's dashboard API + * cannot represent: a visible-item offset inside one upstream API page. + */ +function encodeProjectDashboardCursor( + cursor: Omit, +): string { + const payload = Buffer.from( + JSON.stringify({ + v: 1, + ...cursor, + } satisfies ProjectDashboardCursor), + ).toString("base64url"); + return `${PROJECT_DASHBOARD_CURSOR_PREFIX}${payload}`; +} + +/** Decodes MCP-owned project cursors while leaving raw Sentry API cursors intact. */ +function decodeProjectDashboardCursor( + cursor: string | null | undefined, +): ProjectDashboardCursor | null { + if (!cursor?.startsWith(PROJECT_DASHBOARD_CURSOR_PREFIX)) { + return null; + } + + try { + const value: unknown = JSON.parse( + Buffer.from( + cursor.slice(PROJECT_DASHBOARD_CURSOR_PREFIX.length), + "base64url", + ).toString("utf8"), + ); + if (isProjectDashboardCursor(value)) { + return value; + } + } catch { + // Fall through to the user-facing validation error below. + } + + throw new UserInputError( + "Invalid dashboard cursor. Pass the cursor exactly as returned by find_dashboards.", + ); +} + +/** Ensures org-wide dashboard searches only pass raw Sentry cursors upstream. */ +function assertRawDashboardCursor(cursor: string | null | undefined): void { + if (!cursor?.startsWith(PROJECT_DASHBOARD_CURSOR_PREFIX)) { + return; + } + + throw new UserInputError( + "Project-scoped dashboard cursors can only be used in project-scoped dashboard searches. Start a new org-wide find_dashboards request without a cursor.", + ); +} + +/** Formats the next project cursor without exposing raw API cursors for constrained pages. */ +function formatProjectDashboardNextCursor({ + apiCursor, + offset = 0, + pageLimit, +}: { + apiCursor: string | null; + offset?: number; + pageLimit: number; +}): string | null { + if (!apiCursor) { + return null; + } + + return encodeProjectDashboardCursor({ + apiCursor, + offset, + pageLimit, + }); +} + +/** + * Lists dashboards visible to one project, refilling across API pages while + * keeping Sentry's cursor page size stable for every followed API cursor. + */ +async function listProjectVisibleDashboards({ + apiService, + organizationSlug, + titleQuery, + sort, + limit, + cursor, + projectId, +}: { + apiService: SentryApiService; + organizationSlug: string; + titleQuery?: string | null; + sort: "title" | "-title" | "dateCreated" | "-dateCreated"; + limit: number; + cursor?: string | null; + projectId: string | number; +}): Promise<{ dashboards: DashboardListItem[]; nextCursor: string | null }> { + const projectCursor = decodeProjectDashboardCursor(cursor); + const dashboards: DashboardListItem[] = []; + const seenCursors = new Set(); + const pageLimit = projectCursor?.pageLimit ?? limit; + let pageCursor = projectCursor ? projectCursor.apiCursor : (cursor ?? null); + let visibleOffset = projectCursor?.offset ?? 0; + let nextCursor: string | null = null; + + if (pageCursor) { + seenCursors.add(pageCursor); + } + + do { + const page = await apiService.listDashboards({ + organizationSlug, + query: titleQuery ?? undefined, + sortBy: sort, + limit: pageLimit, + cursor: pageCursor ?? undefined, + }); + + const visiblePage = filterDashboardsByProjectConstraint({ + dashboards: page.dashboards, + projectId, + }); + const offsetVisiblePage = visiblePage.slice(visibleOffset); + const remaining = limit - dashboards.length; + + if (offsetVisiblePage.length > remaining) { + dashboards.push(...offsetVisiblePage.slice(0, remaining)); + return { + dashboards, + nextCursor: encodeProjectDashboardCursor({ + apiCursor: pageCursor, + offset: visibleOffset + remaining, + pageLimit, + }), + }; + } + + dashboards.push(...offsetVisiblePage); + visibleOffset = 0; + + nextCursor = page.nextCursor; + if (!nextCursor || seenCursors.has(nextCursor)) { + nextCursor = null; + break; + } + + seenCursors.add(nextCursor); + pageCursor = nextCursor; + } while (dashboards.length < limit); + + return { + dashboards, + nextCursor: formatProjectDashboardNextCursor({ + apiCursor: nextCursor, + pageLimit, + }), + }; +} + +export default defineTool({ + name: "find_dashboards", + skills: ["inspect"], + requiredScopes: ["org:read"], + description: [ + "Find Sentry dashboards in an organization.", + "", + "Use this tool when you need to:", + "- List dashboards in an organization", + "- Find a dashboard ID before calling get_dashboard_details", + "- Search dashboards by title", + "", + "", + "find_dashboards(organizationSlug='my-organization')", + "find_dashboards(organizationSlug='my-organization', titleQuery='errors')", + "", + "", + "", + "- Dashboard IDs are organization-scoped.", + "- Use `get_dashboard_details` after finding the correct dashboard ID.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + titleQuery: z + .string() + .trim() + .describe("Optional title substring to search for.") + .nullable() + .default(null), + sort: z + .enum(["title", "-title", "dateCreated", "-dateCreated"]) + .describe("Sort order for dashboard results.") + .default("title"), + cursor: z + .string() + .trim() + .describe( + "Optional pagination cursor from a previous response. Reuse cursors only with the same search scope and project constraint.", + ) + .nullable() + .default(null), + limit: z + .number() + .int() + .positive() + .max(100) + .describe("Maximum number of dashboards to return.") + .default(10), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + + const scopedProject = await resolveDashboardProjectConstraint({ + apiService, + organizationSlug, + scopedProjectSlug: context.constraints.projectSlug, + }); + if (scopedProject) { + setTag("project.slug", scopedProject.slug); + setTag("project.id", String(scopedProject.id)); + } + + let dashboards: DashboardListItem[]; + let nextCursor: string | null; + if (scopedProject) { + ({ dashboards, nextCursor } = await listProjectVisibleDashboards({ + apiService, + organizationSlug, + titleQuery: params.titleQuery, + sort: params.sort, + limit: params.limit, + projectId: scopedProject.id, + cursor: params.cursor, + })); + } else { + assertRawDashboardCursor(params.cursor); + ({ dashboards, nextCursor } = await apiService.listDashboards({ + organizationSlug, + query: params.titleQuery ?? undefined, + sortBy: params.sort, + limit: params.limit, + cursor: params.cursor ?? undefined, + })); + } + + return formatDashboardList({ + dashboards, + organizationSlug, + titleQuery: params.titleQuery, + nextCursor, + getDashboardUrl: (dashboard) => + apiService.getDashboardUrl(organizationSlug, String(dashboard.id), { + projectId: + scopedProject?.id ?? + (dashboard.projects.length === 1 ? dashboard.projects[0] : null), + }), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts b/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts new file mode 100644 index 00000000..75b51321 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts @@ -0,0 +1,273 @@ +import { + dashboardDetailsFixture, + dashboardListFixture, + mswServer, +} from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import getDashboardDetails from "./get-dashboard-details.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +const projectConstrainedContext = { + ...context, + constraints: { + ...context.constraints, + projectSlug: "cloudflare-mcp", + }, +}; + +describe("get_dashboard_details", () => { + it("returns dashboard details by ID", async () => { + const result = await getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "101", + }, + context, + ); + + expect(result).toMatchInlineSnapshot(` + "# Dashboard Errors Overview in **sentry-mcp-evals** + + **ID**: 101 + **URL**: [Open Dashboard](https://sentry-mcp-evals.sentry.io/dashboard/101/?project=4509106749636608&statsPeriod=24h) + **Created**: 2025-04-14T10:15:00.000Z + **Created By**: Jane Developer + **Projects**: 4509106749636608 + **Environments**: production + **Period**: 24h + **UTC**: true + **Expired**: false + **Favorited**: yes + + ## Filters + + - **release**: ["frontend@1.2.3"] + - **globalFilter**: [{"key":"browser.name","value":"Chrome"}] + + ## Widgets + + ### 1. Handled Errors + + **ID**: 201 + **Display Type**: line + **Widget Type**: error-events + **Dataset**: discover + **Interval**: 5m + **Layout**: x=0, y=0, w=6, h=2 + **Description**: Handled errors over time + + #### Queries + + - **Query** + - Conditions: \`error.handled:true\` + - Fields: count() + - Aggregates: count() + - Sort: \`-count\` + + ### 2. Top Issues + + **ID**: 202 + **Display Type**: table + **Widget Type**: error-events + **Dataset**: discover + **Interval**: 5m + **Limit**: 5 + **Layout**: x=0, y=2, w=6, h=3 + + #### Queries + + - **Top Issues** + - Conditions: \`is:unresolved\` + - Fields: issue, count() + - Aggregates: count() + - Columns: issue + - Sort: \`-count\` + + ## Response Notes + + - Dashboard widgets include saved query definitions, not live query results." + `); + }); + + it("resolves an exact dashboard title", async () => { + const result = await getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "errors overview", + }, + context, + ); + + expect(result).toContain( + "# Dashboard Errors Overview in **sentry-mcp-evals**", + ); + }); + + it("resolves dashboard titles after applying the active project constraint", async () => { + let requestUrl: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json([ + { + ...dashboardListFixture[0], + title: "Shared Dashboard", + }, + { + ...dashboardListFixture[1], + title: "Shared Dashboard", + }, + ]); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/102/", + () => + HttpResponse.json({ + ...dashboardDetailsFixture, + id: "102", + title: "Shared Dashboard", + projects: [], + widgets: [], + }), + ), + ); + + const result = await getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "Shared Dashboard", + }, + projectConstrainedContext, + ); + + if (!requestUrl) { + throw new Error("Expected dashboard list request to be captured."); + } + expect(new URL(requestUrl).searchParams.has("project")).toBe(false); + expect(result).toContain("**ID**: 102"); + expect(result).toContain( + "https://sentry-mcp-evals.sentry.io/dashboard/102/?project=4509109104082945&statsPeriod=24h", + ); + }); + + it("returns candidates for ambiguous dashboard titles", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/", + () => + HttpResponse.json([ + dashboardListFixture[0], + { + ...dashboardListFixture[1], + title: dashboardListFixture[0].title, + }, + ]), + ), + ); + + await expect( + getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: dashboardListFixture[0].title, + }, + context, + ), + ).rejects.toThrow( + 'Multiple dashboards match the title "Errors Overview". Use a dashboard ID instead.', + ); + }); + + it("rejects missing dashboard titles", async () => { + await expect( + getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "Missing Dashboard", + }, + context, + ), + ).rejects.toThrow( + 'No dashboard with title "Missing Dashboard" found in "sentry-mcp-evals".', + ); + }); + + it("rejects dashboards outside the active project constraint", async () => { + const paths: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json({ + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/101/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json(dashboardDetailsFixture); + }, + ), + ); + + await expect( + getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "101", + }, + projectConstrainedContext, + ), + ).rejects.toThrow(/Dashboard/); + expect(paths).toEqual([ + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/", + "/api/0/organizations/sentry-mcp-evals/dashboards/101/", + ]); + }); + + it("accepts dashboard payloads with no widgets", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/dashboards/101/", + () => + HttpResponse.json({ + ...dashboardDetailsFixture, + widgets: [], + }), + ), + ); + + const result = await getDashboardDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dashboardIdOrTitle: "101", + }, + context, + ); + + expect(result).toContain("No widgets found."); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts b/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts new file mode 100644 index 00000000..39129592 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-dashboard-details.ts @@ -0,0 +1,101 @@ +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"; +import { + assertDashboardWithinProjectConstraint, + formatDashboardDetails, + resolveDashboardProjectConstraint, + resolveDashboardId, +} from "../support/dashboards"; + +export default defineTool({ + name: "get_dashboard_details", + skills: ["inspect"], + requiredScopes: ["org:read"], + description: [ + "Get detailed information about a specific Sentry dashboard.", + "", + "Use this tool when you need to:", + "- Inspect a dashboard's widgets and saved query definitions", + "- View dashboard projects, environments, filters, layout, and widget IDs", + "- Resolve a dashboard by exact title or numeric ID", + "", + "", + "get_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='12345')", + "get_dashboard_details(organizationSlug='my-organization', dashboardIdOrTitle='Errors Overview')", + "", + "", + "", + "- Numeric dashboard IDs are resolved directly.", + "- Title lookups require one exact case-insensitive match. Use `find_dashboards` first if uncertain.", + "- This returns saved widget query definitions, not live widget data.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + dashboardIdOrTitle: z + .string() + .trim() + .min(1) + .describe("The dashboard's numeric ID or exact title."), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + + const scopedProject = await resolveDashboardProjectConstraint({ + apiService, + organizationSlug, + scopedProjectSlug: context.constraints.projectSlug, + }); + if (scopedProject) { + setTag("project.slug", scopedProject.slug); + setTag("project.id", String(scopedProject.id)); + } + + const dashboardId = await resolveDashboardId({ + apiService, + organizationSlug, + dashboardIdOrTitle: params.dashboardIdOrTitle, + projectId: scopedProject?.id, + }); + + const dashboard = await apiService.getDashboard({ + organizationSlug, + dashboardId, + }); + + assertDashboardWithinProjectConstraint({ + dashboard, + scopedProjectSlug: context.constraints.projectSlug, + projectId: scopedProject?.id, + }); + + return formatDashboardDetails({ + dashboard, + organizationSlug, + dashboardUrl: apiService.getDashboardUrl( + organizationSlug, + String(dashboard.id), + { + projectId: + scopedProject?.id ?? + (dashboard.projects.length === 1 ? dashboard.projects[0] : null), + statsPeriod: dashboard.period ?? null, + }, + ), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index 60f5c6d7..dea2ba01 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -4,6 +4,8 @@ import findTeams from "./find-teams"; import findProjects from "./find-projects"; import findReleases from "./find-releases"; import getReleaseDetails from "./get-release-details"; +import findDashboards from "./find-dashboards"; +import getDashboardDetails from "./get-dashboard-details"; import findMonitors from "./find-monitors"; import getMonitorDetails from "./get-monitor-details"; import getIssueDetails from "./get-issue-details"; @@ -50,6 +52,8 @@ const catalogTools = { find_projects: findProjects, find_releases: findReleases, get_release_details: getReleaseDetails, + find_dashboards: findDashboards, + get_dashboard_details: getDashboardDetails, find_monitors: findMonitors, get_monitor_details: getMonitorDetails, get_issue_details: getIssueDetails, diff --git a/packages/mcp-core/src/tools/support/dashboards/index.ts b/packages/mcp-core/src/tools/support/dashboards/index.ts new file mode 100644 index 00000000..15a5f794 --- /dev/null +++ b/packages/mcp-core/src/tools/support/dashboards/index.ts @@ -0,0 +1,380 @@ +import type { + Dashboard, + DashboardListItem, + Project, + DashboardWidget, + SentryApiService, +} from "../../../api-client"; +import { UserInputError } from "../../../errors"; +import { isNumericId } from "../../../utils/slug-validation"; +import { + compactLines, + formatActor, + formatDate, + formatId, + formatUnknown, +} from "../../catalog/support/api-formatting"; + +function formatList(values: Array | undefined): string | null { + if (!values || values.length === 0) { + return null; + } + return values.map(String).join(", "); +} + +function dashboardIncludesProject( + dashboard: Pick, + projectId: string | number | null, +): boolean { + if (!projectId || dashboard.projects.length === 0) { + return true; + } + + return dashboard.projects.some((dashboardProjectId) => + Object.is(String(dashboardProjectId), String(projectId)), + ); +} + +function dashboardProjectConstraintError( + resourceLabel: string, + scopedProjectSlug: string, +) { + return new UserInputError( + `${resourceLabel} is outside the active project constraint. Expected project "${scopedProjectSlug}".`, + ); +} + +/** Resolves an active project slug constraint to its numeric project ID. */ +export async function resolveDashboardProjectConstraint({ + apiService, + organizationSlug, + scopedProjectSlug, +}: { + apiService: SentryApiService; + organizationSlug: string; + scopedProjectSlug?: string | null; +}): Promise { + if (!scopedProjectSlug) { + return null; + } + + return apiService.getProject({ + organizationSlug, + projectSlugOrId: scopedProjectSlug, + }); +} + +/** Filters dashboard list items to those compatible with the active project. */ +export function filterDashboardsByProjectConstraint({ + dashboards, + projectId, +}: { + dashboards: DashboardListItem[]; + projectId?: string | number | null; +}): DashboardListItem[] { + return dashboards.filter((dashboard) => + dashboardIncludesProject(dashboard, projectId ?? null), + ); +} + +/** Throws when a dashboard with explicit projects excludes the active project. */ +export function assertDashboardWithinProjectConstraint({ + dashboard, + scopedProjectSlug, + projectId, +}: { + dashboard: Dashboard; + scopedProjectSlug?: string | null; + projectId?: string | number | null; +}): void { + if ( + scopedProjectSlug && + !dashboardIncludesProject(dashboard, projectId ?? null) + ) { + throw dashboardProjectConstraintError("Dashboard", scopedProjectSlug); + } +} + +function formatDashboardSummary( + dashboard: DashboardListItem, + dashboardUrl: string, +): string { + const widgetCount = dashboard.widgetPreview?.length ?? 0; + const widgetTypes = dashboard.widgetDisplay?.length + ? Array.from(new Set(dashboard.widgetDisplay)).join(", ") + : null; + + return compactLines([ + `## ${dashboard.title}`, + "", + `**ID**: ${formatId(dashboard.id)}`, + `**Widgets**: ${widgetCount}`, + widgetTypes ? `**Widget Types**: ${widgetTypes}` : null, + formatList(dashboard.projects) + ? `**Projects**: ${formatList(dashboard.projects)}` + : null, + formatList(dashboard.environment) + ? `**Environments**: ${formatList(dashboard.environment)}` + : null, + dashboard.createdBy + ? `**Created By**: ${formatActor(dashboard.createdBy)}` + : null, + formatDate(dashboard.dateCreated) + ? `**Created**: ${formatDate(dashboard.dateCreated)}` + : null, + dashboard.lastVisited && formatDate(dashboard.lastVisited) + ? `**Last Visited**: ${formatDate(dashboard.lastVisited)}` + : null, + dashboard.isFavorited ? "**Favorited**: yes" : null, + dashboard.prebuiltId !== null && dashboard.prebuiltId !== undefined + ? `**Prebuilt ID**: ${formatId(dashboard.prebuiltId)}` + : null, + `**URL**: [Open Dashboard](${dashboardUrl})`, + ]).join("\n"); +} + +/** Formats dashboard search results with IDs, metadata, and pagination hints. */ +export function formatDashboardList({ + dashboards, + organizationSlug, + titleQuery, + nextCursor, + getDashboardUrl, +}: { + dashboards: DashboardListItem[]; + organizationSlug: string; + titleQuery?: string | null; + nextCursor?: string | null; + getDashboardUrl: (dashboard: DashboardListItem) => string; +}): string { + let output = `# Dashboards in **${organizationSlug}**\n\n`; + + if (titleQuery) { + output += `**Title query:** "${titleQuery}"\n\n`; + } + + if (dashboards.length === 0) { + output += titleQuery + ? `No dashboards found matching "${titleQuery}".\n` + : "No dashboards found.\n"; + if (nextCursor) { + output += "\n## Response Notes\n\n"; + output += `- More dashboards may be available. Pass \`cursor: "${nextCursor}"\` to fetch the next page.\n`; + } + return output; + } + + output += dashboards + .map((dashboard) => + formatDashboardSummary(dashboard, getDashboardUrl(dashboard)), + ) + .join("\n\n"); + + output += "\n\n## Response Notes\n\n"; + output += + "- Use `get_dashboard_details` with the dashboard ID for widgets and query definitions.\n"; + if (nextCursor) { + output += `- More dashboards are available. Pass \`cursor: "${nextCursor}"\` to fetch the next page.\n`; + } + + return output; +} + +function formatWidgetQuery( + query: DashboardWidget["queries"][number], +): string[] { + const lines = compactLines([ + `- **${query.name || "Query"}**`, + query.conditions ? ` - Conditions: \`${query.conditions}\`` : null, + query.fields?.length ? ` - Fields: ${query.fields.join(", ")}` : null, + query.aggregates?.length + ? ` - Aggregates: ${query.aggregates.join(", ")}` + : null, + query.columns?.length ? ` - Columns: ${query.columns.join(", ")}` : null, + query.orderby ? ` - Sort: \`${query.orderby}\`` : null, + ]); + + return lines; +} + +function formatWidget(widget: DashboardWidget, index: number): string { + const layout = widget.layout; + const layoutText = + layout && typeof layout === "object" + ? ["x", "y", "w", "h"] + .filter((key) => layout[key] !== undefined) + .map((key) => `${key}=${formatUnknown(layout[key])}`) + .join(", ") + : null; + + const lines = compactLines([ + `### ${index + 1}. ${widget.title}`, + "", + `**ID**: ${formatId(widget.id)}`, + `**Display Type**: ${widget.displayType}`, + widget.widgetType ? `**Widget Type**: ${widget.widgetType}` : null, + widget.datasetSource ? `**Dataset**: ${widget.datasetSource}` : null, + widget.interval ? `**Interval**: ${widget.interval}` : null, + widget.limit !== null && widget.limit !== undefined + ? `**Limit**: ${widget.limit}` + : null, + layoutText ? `**Layout**: ${layoutText}` : null, + widget.description ? `**Description**: ${widget.description}` : null, + ]); + + if (widget.queries.length > 0) { + lines.push("", "#### Queries", ""); + for (const query of widget.queries) { + lines.push(...formatWidgetQuery(query)); + } + } + + return lines.join("\n"); +} + +function formatFilters(filters: Record): string[] { + return Object.entries(filters).map( + ([key, value]) => `- **${key}**: ${formatUnknown(value)}`, + ); +} + +/** Formats saved dashboard metadata, filters, widgets, and query definitions. */ +export function formatDashboardDetails({ + dashboard, + organizationSlug, + dashboardUrl, +}: { + dashboard: Dashboard; + organizationSlug: string; + dashboardUrl: string; +}): string { + const lines = compactLines([ + `# Dashboard ${dashboard.title} in **${organizationSlug}**`, + "", + `**ID**: ${formatId(dashboard.id)}`, + `**URL**: [Open Dashboard](${dashboardUrl})`, + formatDate(dashboard.dateCreated) + ? `**Created**: ${formatDate(dashboard.dateCreated)}` + : null, + dashboard.createdBy + ? `**Created By**: ${formatActor(dashboard.createdBy)}` + : null, + formatList(dashboard.projects) + ? `**Projects**: ${formatList(dashboard.projects)}` + : null, + formatList(dashboard.environment) + ? `**Environments**: ${formatList(dashboard.environment)}` + : null, + dashboard.period ? `**Period**: ${dashboard.period}` : null, + dashboard.start ? `**Start**: ${formatDate(dashboard.start)}` : null, + dashboard.end ? `**End**: ${formatDate(dashboard.end)}` : null, + dashboard.utc !== null && dashboard.utc !== undefined + ? `**UTC**: ${formatUnknown(dashboard.utc)}` + : null, + dashboard.expired !== undefined + ? `**Expired**: ${formatUnknown(dashboard.expired)}` + : null, + dashboard.prebuiltId !== null && dashboard.prebuiltId !== undefined + ? `**Prebuilt ID**: ${formatId(dashboard.prebuiltId)}` + : null, + dashboard.isFavorited ? "**Favorited**: yes" : null, + ]); + + const filterLines = formatFilters(dashboard.filters); + if (filterLines.length > 0) { + lines.push("", "## Filters", "", ...filterLines); + } + + lines.push("", "## Widgets", ""); + + if (dashboard.widgets.length === 0) { + lines.push("No widgets found."); + } else { + lines.push( + dashboard.widgets + .map((widget, index) => formatWidget(widget, index)) + .join("\n\n"), + ); + } + + lines.push("", "## Response Notes", ""); + lines.push( + "- Dashboard widgets include saved query definitions, not live query results.", + ); + + return lines.join("\n"); +} + +function formatCandidates(candidates: DashboardListItem[]): string { + return candidates + .slice(0, 5) + .map((dashboard) => `- ${dashboard.title} (ID: ${formatId(dashboard.id)})`) + .join("\n"); +} + +/** Resolves a numeric dashboard ID directly or a single exact title match. */ +export async function resolveDashboardId({ + apiService, + organizationSlug, + dashboardIdOrTitle, + projectId, +}: { + apiService: SentryApiService; + organizationSlug: string; + dashboardIdOrTitle: string; + projectId?: string | number | null; +}): Promise { + const ref = dashboardIdOrTitle.trim(); + if (isNumericId(ref)) { + return ref; + } + + const visibleDashboards: DashboardListItem[] = []; + const exactMatches: DashboardListItem[] = []; + const seenCursors = new Set(); + let cursor: string | null = null; + + do { + const { dashboards, nextCursor } = await apiService.listDashboards({ + organizationSlug, + query: ref, + sortBy: "title", + limit: 100, + cursor, + }); + + const visiblePage = filterDashboardsByProjectConstraint({ + dashboards, + projectId, + }); + visibleDashboards.push(...visiblePage); + exactMatches.push( + ...visiblePage.filter( + (dashboard) => dashboard.title.toLowerCase() === ref.toLowerCase(), + ), + ); + + if (!nextCursor || seenCursors.has(nextCursor)) { + break; + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } while (cursor); + + if (exactMatches.length === 1) { + return String(exactMatches[0]!.id); + } + + if (exactMatches.length > 1) { + throw new UserInputError( + `Multiple dashboards match the title "${ref}". Use a dashboard ID instead.\n\n${formatCandidates(exactMatches)}`, + ); + } + + const candidateText = visibleDashboards.length + ? `\n\nDid you mean:\n${formatCandidates(visibleDashboards)}` + : ""; + throw new UserInputError( + `No dashboard with title "${ref}" found in "${organizationSlug}".${candidateText}`, + ); +} diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index 45c4c511..5137694c 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -44,6 +44,11 @@ export interface ProfilesExplorerUrlOptions { groupByFields?: string[]; } +export interface DashboardUrlOptions { + projectId?: string | number | null; + statsPeriod?: string | null; +} + function deriveSelectedFields( fields?: string[], aggregateFunctions?: string[], @@ -343,6 +348,40 @@ export function getIssueUrl( ); } +/** + * Generates a Sentry dashboard URL. + * @param host The Sentry host (may include regional subdomain for API access) + * @param organizationSlug Organization identifier + * @param dashboardId Dashboard ID + * @param options Optional dashboard view query parameters + * @param protocol Protocol to use when building the web URL + * @returns The complete dashboard URL + */ +export function getDashboardUrl( + host: string, + organizationSlug: string, + dashboardId: string, + options: DashboardUrlOptions = {}, + protocol: SentryProtocol = "https", +): string { + const encodedDashboardId = encodeURIComponent(dashboardId); + const params = new URLSearchParams(); + if (options.projectId !== null && options.projectId !== undefined) { + params.set("project", String(options.projectId)); + } + if (options.statsPeriod) { + params.set("statsPeriod", options.statsPeriod); + } + + const queryString = params.toString(); + return `${getSentryWebBaseUrl( + host, + organizationSlug, + `/dashboard/${encodedDashboardId}/`, + protocol, + )}${queryString ? `?${queryString}` : ""}`; +} + /** * Generates a Sentry preprod snapshot URL. * @param host The Sentry host (may include regional subdomain for API access) diff --git a/packages/mcp-server-mocks/src/fixtures/dashboard-details.json b/packages/mcp-server-mocks/src/fixtures/dashboard-details.json new file mode 100644 index 00000000..03a7467e --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/dashboard-details.json @@ -0,0 +1,95 @@ +{ + "id": "101", + "title": "Errors Overview", + "dateCreated": "2025-04-14T10:15:00.000Z", + "createdBy": { + "id": "1", + "name": "Jane Developer", + "email": "jane@example.com" + }, + "widgets": [ + { + "id": "201", + "title": "Handled Errors", + "description": "Handled errors over time", + "displayType": "line", + "thresholds": null, + "interval": "5m", + "dateCreated": "2025-04-14T10:16:00.000Z", + "dashboardId": "101", + "queries": [ + { + "id": "301", + "name": "", + "fields": ["count()"], + "aggregates": ["count()"], + "columns": [], + "fieldAliases": [], + "conditions": "error.handled:true", + "orderby": "-count", + "widgetId": "201", + "onDemand": [], + "isHidden": false, + "selectedAggregate": 0, + "linkedDashboards": [] + } + ], + "limit": null, + "widgetType": "error-events", + "layout": { "x": 0, "y": 0, "w": 6, "h": 2 }, + "axisRange": null, + "legendType": null, + "datasetSource": "discover", + "changedReason": null + }, + { + "id": "202", + "title": "Top Issues", + "description": null, + "displayType": "table", + "thresholds": null, + "interval": "5m", + "dateCreated": "2025-04-14T10:18:00.000Z", + "dashboardId": "101", + "queries": [ + { + "id": "302", + "name": "Top Issues", + "fields": ["issue", "count()"], + "aggregates": ["count()"], + "columns": ["issue"], + "fieldAliases": [], + "conditions": "is:unresolved", + "orderby": "-count", + "widgetId": "202", + "onDemand": [], + "isHidden": false, + "selectedAggregate": 0, + "linkedDashboards": [] + } + ], + "limit": 5, + "widgetType": "error-events", + "layout": { "x": 0, "y": 2, "w": 6, "h": 3 }, + "axisRange": null, + "legendType": null, + "datasetSource": "discover", + "changedReason": null + } + ], + "filters": { + "release": ["frontend@1.2.3"], + "globalFilter": [{ "key": "browser.name", "value": "Chrome" }] + }, + "permissions": { + "isEditableByEveryone": true, + "teamsWithEditAccess": [] + }, + "isFavorited": true, + "projects": [4509106749636608], + "environment": ["production"], + "period": "24h", + "utc": true, + "expired": false, + "prebuiltId": null +} diff --git a/packages/mcp-server-mocks/src/fixtures/dashboard-list.json b/packages/mcp-server-mocks/src/fixtures/dashboard-list.json new file mode 100644 index 00000000..f12bce74 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/dashboard-list.json @@ -0,0 +1,53 @@ +[ + { + "id": "101", + "title": "Errors Overview", + "dateCreated": "2025-04-14T10:15:00.000Z", + "createdBy": { + "id": "1", + "name": "Jane Developer", + "email": "jane@example.com" + }, + "environment": ["production"], + "filters": {}, + "lastVisited": "2025-04-15T12:00:00.000Z", + "widgetDisplay": ["line", "table"], + "widgetPreview": [ + { + "displayType": "line", + "layout": { "x": 0, "y": 0, "w": 6, "h": 2 } + }, + { + "displayType": "table", + "layout": { "x": 0, "y": 2, "w": 6, "h": 3 } + } + ], + "permissions": { + "isEditableByEveryone": true, + "teamsWithEditAccess": [] + }, + "isFavorited": true, + "projects": [4509106749636608], + "prebuiltId": null + }, + { + "id": "102", + "title": "Errors Overview Copy", + "dateCreated": "2025-04-16T09:00:00.000Z", + "createdBy": null, + "environment": [], + "filters": {}, + "lastVisited": null, + "widgetDisplay": ["big_number"], + "widgetPreview": [ + { + "displayType": "big_number", + "layout": { "x": 0, "y": 0, "w": 2, "h": 1 } + } + ], + "permissions": null, + "isFavorited": false, + "projects": [], + "prebuiltId": null + } +] diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 24f43dca..113c23f1 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -31,6 +31,12 @@ import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" type: "json", }; import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" }; +import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { + type: "json", +}; +import dashboardListFixture from "./fixtures/dashboard-list.json" with { + type: "json", +}; import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { type: "json", }; @@ -364,6 +370,49 @@ export const restHandlers = buildHandlers([ ]); }, }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/dashboards/", + fetch: ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("query")?.toLowerCase(); + const perPage = Number(url.searchParams.get("per_page") ?? "10"); + const cursor = url.searchParams.get("cursor"); + const dashboards = query + ? dashboardListFixture.filter((dashboard) => + dashboard.title.toLowerCase().includes(query), + ) + : dashboardListFixture; + const start = cursor === "dashboard-cursor" ? perPage : 0; + const page = dashboards.slice(start, start + perPage); + const hasMore = start + perPage < dashboards.length; + + return HttpResponse.json(page, { + headers: hasMore + ? { + Link: '; rel="next"; results="true"; cursor="dashboard-cursor"', + } + : undefined, + }); + }, + }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/dashboards/101/", + fetch: () => HttpResponse.json(dashboardDetailsFixture), + }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/dashboards/102/", + fetch: () => + HttpResponse.json({ + ...dashboardDetailsFixture, + id: "102", + title: "Errors Overview Copy", + widgets: [], + isFavorited: false, + }), + }, { method: "post", path: "/api/0/organizations/sentry-mcp-evals/teams/", @@ -1645,6 +1694,8 @@ export { organizationFixture, releaseFixture, clientKeyFixture, + dashboardDetailsFixture, + dashboardListFixture, userFixture, eventsErrorsFixture, eventsErrorsEmptyFixture,