From 1eaff64dd585995c7dfa201b5ee557ee9438446e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 10 Jun 2026 14:33:26 -0700 Subject: [PATCH 1/2] feat(dashboards): Add dashboard catalog tools Add catalog tools to find dashboards and inspect saved dashboard details. Validate dashboard API responses against schemas confirmed from Sentry, resolve active project constraints to numeric project IDs, and filter dashboard visibility locally because the dashboard list API does not support project scoping. Generated dashboard links now use the canonical dashboard route and include project and statsPeriod query parameters when available. Fixes #1073 Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/api-client/client.ts | 79 ++++ packages/mcp-core/src/api-client/schema.ts | 106 +++++ packages/mcp-core/src/api-client/types.ts | 8 + packages/mcp-core/src/skillDefinitions.json | 12 +- packages/mcp-core/src/toolDefinitions.json | 108 +++++ .../src/tools/catalog/find-dashboards.test.ts | 170 ++++++++ .../src/tools/catalog/find-dashboards.ts | 110 +++++ .../catalog/get-dashboard-details.test.ts | 273 +++++++++++++ .../tools/catalog/get-dashboard-details.ts | 101 +++++ packages/mcp-core/src/tools/catalog/index.ts | 4 + .../src/tools/support/dashboards/index.ts | 376 ++++++++++++++++++ packages/mcp-core/src/utils/url-utils.ts | 39 ++ .../src/fixtures/dashboard-details.json | 95 +++++ .../src/fixtures/dashboard-list.json | 53 +++ packages/mcp-server-mocks/src/index.ts | 51 +++ 15 files changed, 1584 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-core/src/tools/catalog/find-dashboards.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/find-dashboards.ts create mode 100644 packages/mcp-core/src/tools/catalog/get-dashboard-details.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/get-dashboard-details.ts create mode 100644 packages/mcp-core/src/tools/support/dashboards/index.ts create mode 100644 packages/mcp-server-mocks/src/fixtures/dashboard-details.json create mode 100644 packages/mcp-server-mocks/src/fixtures/dashboard-list.json 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..c449cd21 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." + }, + { + "type": "null" + } + ], + "description": "Optional pagination cursor from a previous response.", + "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..6ed83f8c --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts @@ -0,0 +1,170 @@ +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("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..ff7b6dfa --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.ts @@ -0,0 +1,110 @@ +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 { + filterDashboardsByProjectConstraint, + formatDashboardList, + resolveDashboardProjectConstraint, +} from "../support/dashboards"; + +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.") + .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)); + } + + const { dashboards, nextCursor } = await apiService.listDashboards({ + organizationSlug, + query: params.titleQuery ?? undefined, + sortBy: params.sort, + limit: params.limit, + cursor: params.cursor ?? undefined, + }); + + const visibleDashboards = filterDashboardsByProjectConstraint({ + dashboards, + projectId: scopedProject?.id, + }); + + return formatDashboardList({ + dashboards: visibleDashboards, + 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..749e984f --- /dev/null +++ b/packages/mcp-core/src/tools/support/dashboards/index.ts @@ -0,0 +1,376 @@ +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"; + 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, From e023ca0f3fecda61801bb8441e0630f9a1c5654e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 10 Jun 2026 15:57:37 -0700 Subject: [PATCH 2/2] fix(dashboards): Page through project-filtered results Project-constrained dashboard searches filtered each Sentry API page locally and could stop before later visible dashboards. Preserve Sentry's upstream cursor page size and use an MCP-owned cursor to resume within a filtered page when needed. Reject MCP-owned project dashboard cursors in org-wide searches before they reach Sentry, and document that dashboard cursors must stay within the same search scope and project constraint. Add regression coverage for empty filtered pages, overflow within a visible page, first-page cursor resume, and cross-scope cursor rejection. Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/toolDefinitions.json | 4 +- .../src/tools/catalog/find-dashboards.test.ts | 344 ++++++++++++++++++ .../src/tools/catalog/find-dashboards.ts | 230 +++++++++++- .../src/tools/support/dashboards/index.ts | 4 + 4 files changed, 566 insertions(+), 16 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index c449cd21..147a73b7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -303,13 +303,13 @@ "anyOf": [ { "type": "string", - "description": "Optional pagination cursor from a previous response." + "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.", + "description": "Optional pagination cursor from a previous response. Reuse cursors only with the same search scope and project constraint.", "default": null }, "limit": { diff --git a/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts b/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts index 6ed83f8c..2c10661a 100644 --- a/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.test.ts @@ -134,6 +134,350 @@ describe("find_dashboards", () => { ); }); + 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( diff --git a/packages/mcp-core/src/tools/catalog/find-dashboards.ts b/packages/mcp-core/src/tools/catalog/find-dashboards.ts index ff7b6dfa..d9ed1ea7 100644 --- a/packages/mcp-core/src/tools/catalog/find-dashboards.ts +++ b/packages/mcp-core/src/tools/catalog/find-dashboards.ts @@ -1,5 +1,7 @@ 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"; @@ -10,6 +12,194 @@ import { 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"], @@ -48,7 +238,9 @@ export default defineTool({ cursor: z .string() .trim() - .describe("Optional pagination cursor from a previous response.") + .describe( + "Optional pagination cursor from a previous response. Reuse cursors only with the same search scope and project constraint.", + ) .nullable() .default(null), limit: z @@ -81,21 +273,31 @@ export default defineTool({ setTag("project.id", String(scopedProject.id)); } - const { dashboards, nextCursor } = await apiService.listDashboards({ - organizationSlug, - query: params.titleQuery ?? undefined, - sortBy: params.sort, - limit: params.limit, - cursor: params.cursor ?? undefined, - }); - - const visibleDashboards = filterDashboardsByProjectConstraint({ - dashboards, - projectId: 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: visibleDashboards, + dashboards, organizationSlug, titleQuery: params.titleQuery, nextCursor, diff --git a/packages/mcp-core/src/tools/support/dashboards/index.ts b/packages/mcp-core/src/tools/support/dashboards/index.ts index 749e984f..15a5f794 100644 --- a/packages/mcp-core/src/tools/support/dashboards/index.ts +++ b/packages/mcp-core/src/tools/support/dashboards/index.ts @@ -157,6 +157,10 @@ export function formatDashboardList({ 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; }