From 33cfb2cbcfe6e404095768f0aef27cc82b6427c1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 5 Jun 2026 17:58:49 +0200 Subject: [PATCH 1/6] feat(monitors): Add monitor catalog tools Add catalog-only tools for discovering cron monitors and inspecting monitor details, check-ins, and stats. Wire monitor URLs through get_sentry_resource while preserving the default direct tool surface. Co-Authored-By: Codex --- .../mcp-core/src/api-client/client.test.ts | 54 +++ packages/mcp-core/src/api-client/client.ts | 263 +++++++++++- packages/mcp-core/src/api-client/schema.ts | 74 ++++ packages/mcp-core/src/api-client/types.ts | 12 + packages/mcp-core/src/skillDefinitions.json | 18 +- packages/mcp-core/src/toolDefinitions.json | 227 ++++++++++- .../src/tools/catalog/find-monitors.test.ts | 137 +++++++ .../src/tools/catalog/find-monitors.ts | 183 +++++++++ .../tools/catalog/get-monitor-details.test.ts | 379 ++++++++++++++++++ .../src/tools/catalog/get-monitor-details.ts | 314 +++++++++++++++ .../get-sentry-resource-resolve.test.ts | 42 +- .../tools/catalog/get-sentry-resource.test.ts | 220 +++++++++- .../src/tools/catalog/get-sentry-resource.ts | 93 +++-- packages/mcp-core/src/tools/catalog/index.ts | 4 + packages/mcp-core/src/utils/url-utils.test.ts | 29 ++ packages/mcp-core/src/utils/url-utils.ts | 9 +- .../src/fixtures/monitor-checkins.json | 34 ++ .../src/fixtures/monitor-stats.json | 20 + .../src/fixtures/monitor.json | 52 +++ packages/mcp-server-mocks/src/index.ts | 42 ++ 20 files changed, 2135 insertions(+), 71 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/find-monitors.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/find-monitors.ts create mode 100644 packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/get-monitor-details.ts create mode 100644 packages/mcp-server-mocks/src/fixtures/monitor-checkins.json create mode 100644 packages/mcp-server-mocks/src/fixtures/monitor-stats.json create mode 100644 packages/mcp-server-mocks/src/fixtures/monitor.json diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 956f0f4c..226b29ac 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -1,4 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { mswServer } from "@sentry/mcp-server-mocks"; import { SentryApiService } from "./client"; import { ConfigurationError } from "../errors"; @@ -327,6 +329,58 @@ describe("getEventsExplorerUrl", () => { }); }); +describe("monitor time parameters", () => { + it("defaults blank monitor statsPeriod values to a 24h window", async () => { + const requestUrls: URL[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/my-org/monitors/nightly-import/checkins/", + ({ request }) => { + requestUrls.push(new URL(request.url)); + return HttpResponse.json([]); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/my-org/monitors/nightly-import/stats/", + ({ request }) => { + requestUrls.push(new URL(request.url)); + return HttpResponse.json([]); + }, + ), + ); + + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + await apiService.listMonitorCheckIns({ + organizationSlug: "my-org", + monitorSlug: "nightly-import", + statsPeriod: " ", + limit: 10, + }); + await apiService.getMonitorStats({ + organizationSlug: "my-org", + monitorSlug: "nightly-import", + statsPeriod: " ", + }); + + expect(requestUrls).toHaveLength(2); + const [checkInsUrl, statsUrl] = requestUrls as [URL, URL]; + expect(checkInsUrl.pathname).toBe( + "/api/0/organizations/my-org/monitors/nightly-import/checkins/", + ); + expect(checkInsUrl.searchParams.get("statsPeriod")).toBe("24h"); + expect(statsUrl.pathname).toBe( + "/api/0/organizations/my-org/monitors/nightly-import/stats/", + ); + expect(statsUrl.searchParams.get("statsPeriod")).toBeNull(); + expect(statsUrl.searchParams.get("since")).not.toBeNull(); + expect(statsUrl.searchParams.get("until")).not.toBeNull(); + }); +}); + describe("network error handling", () => { let originalFetch: typeof globalThis.fetch; diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index db81433f..410050da 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -34,6 +34,10 @@ import { ProjectSchema, CommitListSchema, DeployListSchema, + MonitorCheckInListSchema, + MonitorListSchema, + MonitorSchema, + MonitorStatsSchema, RepositoryListSchema, ReleaseDetailsSchema, ReleaseListSchema, @@ -87,6 +91,10 @@ import type { ExternalIssueList, CommitList, DeployList, + Monitor, + MonitorCheckInList, + MonitorList, + MonitorStats, OrganizationList, Project, ProjectList, @@ -122,6 +130,11 @@ const NETWORK_ERROR_MESSAGES: Record = { ECONNRESET: "Connection reset. Try again in a moment.", }; +function normalizeStatsPeriod(statsPeriod?: string): string | undefined { + const normalized = statsPeriod?.trim(); + return normalized || undefined; +} + function getNextCursor(linkHeader: string | null): string | null { if (!linkHeader) { return null; @@ -405,6 +418,80 @@ export class SentryApiService { } } + private applyStatsMixinTimeParams( + queryParams: URLSearchParams, + statsPeriod?: string, + start?: string, + end?: string, + resolutionSeconds?: number, + ): void { + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", + ); + } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together for absolute time ranges.", + ); + } + + let since: number; + let until: number; + + if (start && end) { + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + throw new ApiValidationError( + "start and end parameters must be valid ISO 8601 date strings.", + ); + } + since = Math.floor(startMs / 1000); + until = Math.floor(endMs / 1000); + } else { + const period = statsPeriod ?? "24h"; + const match = /^(\d+)([smhdw])$/.exec(period); + if (!match) { + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + const amount = Number(match[1]); + const unit = match[2]; + let unitSeconds: number; + switch (unit) { + case "s": + unitSeconds = 1; + break; + case "m": + unitSeconds = 60; + break; + case "h": + unitSeconds = 60 * 60; + break; + case "d": + unitSeconds = 60 * 60 * 24; + break; + case "w": + unitSeconds = 60 * 60 * 24 * 7; + break; + default: + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + until = Math.floor(Date.now() / 1000); + since = until - amount * unitSeconds; + } + + queryParams.set("since", String(since)); + queryParams.set("until", String(until)); + if (resolutionSeconds !== undefined) { + queryParams.set("resolution", `${resolutionSeconds}s`); + } + } + /** * Internal method for making authenticated requests to Sentry API. * @@ -763,12 +850,17 @@ export class SentryApiService { ); } - getMonitorUrl(organizationSlug: string, monitorSlug: string): string { + getMonitorUrl( + organizationSlug: string, + monitorSlug: string, + projectSlug?: string, + ): string { return getMonitorUrlUtil( this.host, organizationSlug, monitorSlug, this.protocol, + projectSlug, ); } @@ -1761,6 +1853,175 @@ export class SentryApiService { return CommitListSchema.parse(body); } + async listMonitors( + { + organizationSlug, + projectSlug, + environment, + owner, + query, + limit, + }: { + organizationSlug: string; + projectSlug?: string; + environment?: string; + owner?: string; + query?: string; + limit?: number; + }, + opts?: RequestOptions, + ): Promise { + const searchQuery = new URLSearchParams(); + if (projectSlug) { + searchQuery.append("projectSlug", projectSlug); + } + if (environment) { + searchQuery.append("environment", environment); + } + if (owner) { + searchQuery.append("owner", owner); + } + if (query) { + searchQuery.set("query", query); + } + if (limit !== undefined) { + searchQuery.set("per_page", String(limit)); + } + + const body = await this.requestJSON( + searchQuery.toString() + ? `/organizations/${organizationSlug}/monitors/?${searchQuery.toString()}` + : `/organizations/${organizationSlug}/monitors/`, + undefined, + opts, + ); + return MonitorListSchema.parse(body); + } + + async getMonitorDetails( + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + }: { + organizationSlug: string; + projectSlug?: string; + monitorSlug: string; + environment?: string; + }, + opts?: RequestOptions, + ): Promise { + const searchQuery = new URLSearchParams(); + if (environment) { + searchQuery.append("environment", environment); + } + + const encodedMonitor = encodeURIComponent(monitorSlug); + const path = projectSlug + ? `/projects/${organizationSlug}/${projectSlug}/monitors/${encodedMonitor}/` + : `/organizations/${organizationSlug}/monitors/${encodedMonitor}/`; + const body = await this.requestJSON( + searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, + undefined, + opts, + ); + return MonitorSchema.parse(body); + } + + async listMonitorCheckIns( + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + statsPeriod, + start, + end, + limit, + }: { + organizationSlug: string; + projectSlug?: string; + monitorSlug: string; + environment?: string; + statsPeriod?: string; + start?: string; + end?: string; + limit?: number; + }, + opts?: RequestOptions, + ): Promise { + const searchQuery = new URLSearchParams(); + if (environment) { + searchQuery.append("environment", environment); + } + if (limit !== undefined) { + searchQuery.set("per_page", String(limit)); + } + const effectiveStatsPeriod = + start || end ? undefined : (normalizeStatsPeriod(statsPeriod) ?? "24h"); + this.applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); + + const encodedMonitor = encodeURIComponent(monitorSlug); + const path = projectSlug + ? `/projects/${organizationSlug}/${projectSlug}/monitors/${encodedMonitor}/checkins/` + : `/organizations/${organizationSlug}/monitors/${encodedMonitor}/checkins/`; + const body = await this.requestJSON( + searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, + undefined, + opts, + ); + return MonitorCheckInListSchema.parse(body); + } + + async getMonitorStats( + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + statsPeriod, + start, + end, + rollup, + }: { + organizationSlug: string; + projectSlug?: string; + monitorSlug: string; + environment?: string; + statsPeriod?: string; + start?: string; + end?: string; + rollup?: number; + }, + opts?: RequestOptions, + ): Promise { + const searchQuery = new URLSearchParams(); + if (environment) { + searchQuery.append("environment", environment); + } + const effectiveStatsPeriod = + start || end ? undefined : (normalizeStatsPeriod(statsPeriod) ?? "24h"); + this.applyStatsMixinTimeParams( + searchQuery, + effectiveStatsPeriod, + start, + end, + rollup, + ); + + const encodedMonitor = encodeURIComponent(monitorSlug); + const path = projectSlug + ? `/projects/${organizationSlug}/${projectSlug}/monitors/${encodedMonitor}/stats/` + : `/organizations/${organizationSlug}/monitors/${encodedMonitor}/stats/`; + const body = await this.requestJSON( + searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, + undefined, + opts, + ); + return MonitorStatsSchema.parse(body); + } + /** * Lists available tags for search queries. * diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 51dd8fff..1de2e61b 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -337,6 +337,80 @@ const ApiActorSchema = z }) .passthrough(); +const ApiProjectRefSchema = z + .object({ + id: ApiResourceIdSchema.optional(), + slug: z.string().nullable().optional(), + name: z.string().nullable().optional(), + }) + .passthrough(); + +export const MonitorEnvironmentSchema = z + .object({ + id: ApiResourceIdSchema.optional(), + name: z.string().nullable().optional(), + status: z.string().nullable().optional(), + dateCreated: z.string().datetime().nullable().optional(), + lastCheckIn: z.string().datetime().nullable().optional(), + nextCheckIn: z.string().datetime().nullable().optional(), + nextCheckInLatest: z.string().datetime().nullable().optional(), + isMuted: z.boolean().nullable().optional(), + activeIncident: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .passthrough(); + +export const MonitorSchema = z + .object({ + id: ApiResourceIdSchema.optional(), + slug: z.string(), + name: z.string().nullable().optional(), + status: z.string().nullable().optional(), + type: z.string().nullable().optional(), + isMuted: z.boolean().nullable().optional(), + isUpserting: z.boolean().nullable().optional(), + project: ApiProjectRefSchema.nullable().optional(), + owner: z.union([z.string(), ApiActorSchema]).nullable().optional(), + dateCreated: z.string().datetime().nullable().optional(), + nextCheckIn: z.string().datetime().nullable().optional(), + lastCheckIn: z.string().datetime().nullable().optional(), + config: z.record(z.string(), z.unknown()).nullable().optional(), + environments: z.array(MonitorEnvironmentSchema).optional(), + alertRule: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +export const MonitorListSchema = z.array(MonitorSchema); + +export const MonitorCheckInSchema = z + .object({ + id: ApiResourceIdSchema.optional(), + checkInId: z.string().nullable().optional(), + status: z.string().nullable().optional(), + duration: z.number().nullable().optional(), + dateCreated: z.string().datetime().nullable().optional(), + dateAdded: z.string().datetime().nullable().optional(), + dateUpdated: z.string().datetime().nullable().optional(), + dateInProgress: z.string().datetime().nullable().optional(), + dateClock: z.string().datetime().nullable().optional(), + expectedTime: z.string().datetime().nullable().optional(), + environment: z.string().nullable().optional(), + monitorConfig: z.record(z.string(), z.unknown()).optional(), + groups: z + .array(z.union([z.string(), z.record(z.string(), z.unknown())])) + .optional(), + }) + .passthrough(); + +export const MonitorCheckInListSchema = z.array(MonitorCheckInSchema); + +export const MonitorStatSchema = z + .object({ + ts: z.number(), + }) + .passthrough(); + +export const MonitorStatsSchema = z.array(MonitorStatSchema); + export const ReleaseDetailsSchema = ReleaseSchema.extend({ adoptionStages: z.unknown().optional(), authors: z.array(ApiActorSchema).optional(), diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 08462342..c19dfb28 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -71,6 +71,12 @@ import type { IssueListSchema, IssueSchema, IssueTagValuesSchema, + MonitorCheckInListSchema, + MonitorCheckInSchema, + MonitorListSchema, + MonitorSchema, + MonitorStatsSchema, + MonitorStatSchema, OrganizationListSchema, OrganizationSchema, ProfileChunkResponseSchema, @@ -112,6 +118,9 @@ export type Commit = z.infer; export type Issue = z.infer; export type IssueActivity = z.infer; export type IssueComment = z.infer; +export type Monitor = z.infer; +export type MonitorCheckIn = z.infer; +export type MonitorStat = z.infer; // Individual event types export type ErrorEvent = z.infer; @@ -151,6 +160,9 @@ export type IssueActivityList = z.infer< typeof IssueActivityListResponseSchema >["activity"]; export type IssueCommentList = z.infer; +export type MonitorList = z.infer; +export type MonitorCheckInList = z.infer; +export type MonitorStats = z.infer; export type EventAttachmentList = z.infer; export type TagList = z.infer; export type ClientKeyList = z.infer; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 65b727bb..6ca0479b 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,8 +5,13 @@ "description": "Search for errors, analyze traces, and explore event details", "defaultEnabled": true, "order": 1, - "toolCount": 22, + "toolCount": 24, "tools": [ + { + "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", + "requiredScopes": ["org:read"] + }, { "name": "find_organizations", "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.", @@ -57,6 +62,11 @@ "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_events or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", "requiredScopes": ["project:read"] }, + { + "name": "get_monitor_details", + "description": "Get details for a Sentry cron monitor.\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', statsPeriod='7d')\n", + "requiredScopes": ["org:read"] + }, { "name": "get_profile", "description": "Analyze CPU profiling data to identify performance bottlenecks and detect regressions.\n\nUSE THIS TOOL WHEN:\n- User asks why a specific endpoint/transaction is slow\n- User wants to understand where CPU time is spent\n- User asks about performance bottlenecks\n- User wants to compare performance between time periods\n- User shares a Sentry profile URL\n\nRETURNS:\n- Hot paths (call stacks consuming the most CPU time)\n- Performance percentiles (p75, p95, p99) for each function\n- User code vs library code breakdown\n- Actionable recommendations for optimization\n- Regression analysis when comparing periods\n\n\n### Analyze from URL (with transaction name)\n```\nget_profile(\n profileUrl='https://my-org.sentry.io/explore/profiling/profile/backend/flamegraph/?profilerId=abc123',\n transactionName='/api/users'\n)\n```\n\n### Analyze by transaction name\n```\nget_profile(\n organizationSlug='my-org',\n transactionName='/api/users',\n projectSlugOrId='backend'\n)\n```\n\n### Compare performance between periods\n```\nget_profile(\n organizationSlug='my-org',\n transactionName='/api/users',\n projectSlugOrId='backend',\n statsPeriod='7d',\n compareAgainstPeriod='14d'\n)\n```\n\n\n\n- Use `focusOnUserCode: true` (default) to filter out library code\n- High p99 relative to p75 indicates inconsistent performance\n- Use compareAgainstPeriod to detect regressions over time\n- Transaction names are case-sensitive\n", @@ -79,7 +89,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Use the Sentry tool `get_snapshot_image` for full-resolution image bytes.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Use the Sentry tool `get_snapshot_image` for full-resolution image bytes.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, { @@ -154,7 +164,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, { @@ -254,7 +264,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 823b32a3..4119c40b 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -293,6 +293,97 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "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", + "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 + }, + "projectSlug": { + "anyOf": [ + { + "type": "string", + "description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible." + }, + { + "type": "null" + } + ], + "description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.", + "default": null + }, + "environment": { + "anyOf": [ + { + "type": "string", + "description": "Optional environment name to limit monitor state." + }, + { + "type": "null" + } + ], + "description": "Optional environment name to limit monitor state.", + "default": null + }, + "owner": { + "anyOf": [ + { + "type": "string", + "description": "Optional owner filter, such as `user:123`, `team:456`, `myteams`, or `unassigned`." + }, + { + "type": "null" + } + ], + "description": "Optional owner filter, such as `user:123`, `team:456`, `myteams`, or `unassigned`.", + "default": null + }, + "query": { + "anyOf": [ + { + "type": "string", + "description": "Optional search query for monitor name or slug." + }, + { + "type": "null" + } + ], + "description": "Optional search query for monitor name or slug.", + "default": null + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Maximum number of monitors 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_organizations", "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.", @@ -773,6 +864,135 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "get_monitor_details", + "description": "Get details for a Sentry cron monitor.\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', statsPeriod='7d')\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 + }, + "projectSlugOrId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional project slug or numeric ID. Use this to disambiguate monitors with the same slug or to scope a monitor URL that includes a project segment." + }, + "monitorSlug": { + "type": "string", + "minLength": 1, + "description": "Monitor slug or GUID." + }, + "environment": { + "anyOf": [ + { + "type": "string", + "description": "Optional environment name to filter monitor environments, recent check-ins, and stats." + }, + { + "type": "null" + } + ], + "description": "Optional environment name to filter monitor environments, recent check-ins, and stats.", + "default": null + }, + "statsPeriod": { + "anyOf": [ + { + "type": "string", + "description": "Relative time range, such as `24h`, `7d`, or `14d`. Defaults to `24h` when `start` and `end` are omitted." + }, + { + "type": "null" + } + ], + "description": "Relative time range, such as `24h`, `7d`, or `14d`. Defaults to `24h` when `start` and `end` are omitted.", + "default": null + }, + "start": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "description": "Absolute start time. Must be provided with `end`; do not combine with `statsPeriod`." + }, + { + "type": "null" + } + ], + "description": "Absolute start time. Must be provided with `end`; do not combine with `statsPeriod`.", + "default": null + }, + "end": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "description": "Absolute end time. Must be provided with `start`; do not combine with `statsPeriod`." + }, + { + "type": "null" + } + ], + "description": "Absolute end time. Must be provided with `start`; do not combine with `statsPeriod`.", + "default": null + }, + "checkInLimit": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 50, + "description": "Maximum number of recent check-ins to include.", + "default": 10 + }, + "includeStats": { + "type": "boolean", + "description": "Include aggregate check-in stats for the selected time range.", + "default": true + }, + "rollupSeconds": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Optional stats bucket size in seconds. Omit this to let Sentry choose an appropriate rollup." + }, + { + "type": "null" + } + ], + "description": "Optional stats bucket size in seconds. Omit this to let Sentry choose an appropriate rollup.", + "default": null + } + }, + "required": ["organizationSlug", "monitorSlug"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["org:read"], + "skills": ["inspect"], + "surface": "catalog" + }, { "name": "get_profile", "description": "Analyze CPU profiling data to identify performance bottlenecks and detect regressions.\n\nUSE THIS TOOL WHEN:\n- User asks why a specific endpoint/transaction is slow\n- User wants to understand where CPU time is spent\n- User asks about performance bottlenecks\n- User wants to compare performance between time periods\n- User shares a Sentry profile URL\n\nRETURNS:\n- Hot paths (call stacks consuming the most CPU time)\n- Performance percentiles (p75, p95, p99) for each function\n- User code vs library code breakdown\n- Actionable recommendations for optimization\n- Regression analysis when comparing periods\n\n\n### Analyze from URL (with transaction name)\n```\nget_profile(\n profileUrl='https://my-org.sentry.io/explore/profiling/profile/backend/flamegraph/?profilerId=abc123',\n transactionName='/api/users'\n)\n```\n\n### Analyze by transaction name\n```\nget_profile(\n organizationSlug='my-org',\n transactionName='/api/users',\n projectSlugOrId='backend'\n)\n```\n\n### Compare performance between periods\n```\nget_profile(\n organizationSlug='my-org',\n transactionName='/api/users',\n projectSlugOrId='backend',\n statsPeriod='7d',\n compareAgainstPeriod='14d'\n)\n```\n\n\n\n- Use `focusOnUserCode: true` (default) to filter out library code\n- High p99 relative to p75 indicates inconsistent performance\n- Use compareAgainstPeriod to detect regressions over time\n- Transaction names are case-sensitive\n", @@ -1012,7 +1232,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Use the Sentry tool `get_snapshot_image` for full-resolution image bytes.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Use the Sentry tool `get_snapshot_image` for full-resolution image bytes.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "inputSchema": { "type": "object", "properties": { @@ -1031,14 +1251,15 @@ "ai_conversation", "breadcrumbs", "replay", + "monitor", "snapshot", "snapshotImage" ], - "description": "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`." + "description": "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`." }, "resourceId": { "type": "string", - "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL." + "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL." }, "organizationSlug": { "type": "string", diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.test.ts b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts new file mode 100644 index 00000000..ed7b9a2e --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts @@ -0,0 +1,137 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import findMonitors from "./find-monitors.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +describe("find_monitors", () => { + it("serializes cron monitors", async () => { + const result = await findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: null, + environment: null, + owner: null, + query: null, + limit: 10, + }, + context, + ); + + expect(result).toMatchInlineSnapshot(` + "# Cron Monitors in **sentry-mcp-evals** + + ## Nightly Import + + **Slug**: nightly-import + **ID**: 4509100000000001 + **Project**: cloudflare-mcp + **Status**: ok + **Owner**: the-goats + **Last Check-In**: 2025-04-14T02:00:13.000Z + **Next Check-In**: 2025-04-15T02:00:00.000Z + **URL**: [Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/) + + ### Schedule + + **schedule**: ["crontab","0 2 * * *"] + **schedule_type**: crontab + **checkin_margin**: 5 + **max_runtime**: 30 + + ### Environments + + - production - ok (last check-in 2025-04-14T02:00:13.000Z) + - staging - missed_checkin (last check-in 2025-04-13T02:00:18.000Z) + + ## Response Notes + + - Use \`get_monitor_details\` with a monitor slug for check-ins and stats. + - Monitor issue searches commonly use \`monitor.slug:\`. + " + `); + }); + + it("filters by projectSlug instead of numeric project for project slugs", async () => { + let requestUrl: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json([]); + }, + ), + ); + + await findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "backend", + environment: null, + owner: null, + query: null, + limit: 10, + }, + context, + ); + + expect(requestUrl).not.toBeNull(); + const params = new URL(requestUrl ?? "").searchParams; + expect(params.get("projectSlug")).toBe("backend"); + expect(params.get("project")).toBeNull(); + }); + + it("encodes monitor slugs in web links", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/", + () => + HttpResponse.json([ + { + id: "4509100000000002", + slug: "nightly/import 1", + name: "Nightly Import 1", + status: "ok", + project: { + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + config: { + schedule_type: "crontab", + schedule: ["crontab", "0 2 * * *"], + }, + environments: [], + }, + ]), + ), + ); + + const result = await findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: null, + environment: null, + owner: null, + query: null, + limit: 10, + }, + context, + ); + + expect(result).toContain( + "[Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/)", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.ts b/packages/mcp-core/src/tools/catalog/find-monitors.ts new file mode 100644 index 00000000..40a468c1 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -0,0 +1,183 @@ +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 { Monitor } from "../../api-client/types"; +import type { ServerContext } from "../../types"; +import { + ParamOrganizationSlug, + ParamProjectSlugOrAll, + ParamRegionUrl, +} from "../../schema"; +import { + compactLines, + formatActor, + formatDate, + formatId, + formatUnknown, +} from "./support/api-formatting"; + +function formatProject(monitor: Monitor): string | null { + if (!monitor.project) { + return null; + } + + return monitor.project.slug ?? monitor.project.name ?? null; +} + +function formatMonitorConfig(monitor: Monitor): string[] { + const config = monitor.config; + if (!config) { + return []; + } + + return ["schedule", "schedule_type", "checkin_margin", "max_runtime"] + .filter((key) => config[key] !== undefined) + .map((key) => `**${key}**: ${formatUnknown(config[key])}`); +} + +function formatMonitor(monitor: Monitor, monitorUrl: string): string { + const project = formatProject(monitor); + const owner = monitor.owner ? formatActor(monitor.owner) : null; + const environments = monitor.environments ?? []; + + const lines = compactLines([ + `## ${monitor.name ?? monitor.slug}`, + "", + `**Slug**: ${monitor.slug}`, + `**ID**: ${formatId(monitor.id)}`, + project ? `**Project**: ${project}` : null, + monitor.status ? `**Status**: ${monitor.status}` : null, + monitor.type ? `**Type**: ${monitor.type}` : null, + owner ? `**Owner**: ${owner}` : null, + formatDate(monitor.lastCheckIn) + ? `**Last Check-In**: ${formatDate(monitor.lastCheckIn)}` + : null, + formatDate(monitor.nextCheckIn) + ? `**Next Check-In**: ${formatDate(monitor.nextCheckIn)}` + : null, + `**URL**: [Open Monitor](${monitorUrl})`, + ]); + + const configLines = formatMonitorConfig(monitor); + if (configLines.length > 0) { + lines.push("", "### Schedule", "", ...configLines); + } + + if (environments.length > 0) { + lines.push("", "### Environments", ""); + for (const environment of environments.slice(0, 5)) { + const label = environment.name ?? "unknown"; + const status = environment.status ? ` - ${environment.status}` : ""; + const lastCheckIn = formatDate(environment.lastCheckIn); + lines.push( + `- ${label}${status}${lastCheckIn ? ` (last check-in ${lastCheckIn})` : ""}`, + ); + } + if (environments.length > 5) { + lines.push(`- ...and ${environments.length - 5} more`); + } + } + + return lines.join("\n"); +} + +export default defineTool({ + name: "find_monitors", + skills: ["inspect"], + requiredScopes: ["org:read"], + description: [ + "Find Sentry cron monitors.", + "", + "Use this tool when you need to:", + "- List cron monitors in an organization", + "- Find a monitor by name or slug before getting details", + "- Check monitor status, owner, project, schedule, or recent environment state", + "", + "", + "find_monitors(organizationSlug='my-organization')", + "find_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlugOrAll.nullable().default(null), + environment: z + .string() + .trim() + .describe("Optional environment name to limit monitor state.") + .nullable() + .default(null), + owner: z + .string() + .trim() + .describe( + "Optional owner filter, such as `user:123`, `team:456`, `myteams`, or `unassigned`.", + ) + .nullable() + .default(null), + query: z + .string() + .trim() + .describe("Optional search query for monitor name or slug.") + .nullable() + .default(null), + limit: z + .number() + .int() + .positive() + .max(100) + .describe("Maximum number of monitors 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 monitors = await apiService.listMonitors({ + organizationSlug, + projectSlug: + params.projectSlug && params.projectSlug !== "all" + ? params.projectSlug + : undefined, + environment: params.environment ?? undefined, + owner: params.owner ?? undefined, + query: params.query ?? undefined, + limit: params.limit, + }); + + let output = `# Cron Monitors in **${organizationSlug}**\n\n`; + if (monitors.length === 0) { + output += "No monitors found.\n"; + return output; + } + + output += monitors + .slice(0, params.limit) + .map((monitor) => { + const project = formatProject(monitor); + return formatMonitor( + monitor, + apiService.getMonitorUrl( + organizationSlug, + monitor.slug, + project ?? undefined, + ), + ); + }) + .join("\n\n"); + output += "\n\n## Response Notes\n\n"; + output += + "- Use `get_monitor_details` with a monitor slug for check-ins and stats.\n"; + output += "- Monitor issue searches commonly use `monitor.slug:`.\n"; + return output; + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts new file mode 100644 index 00000000..4ae21521 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -0,0 +1,379 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import getMonitorDetails from "./get-monitor-details.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +describe("get_monitor_details", () => { + it("serializes monitor details", async () => { + const result = await getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + context, + ); + + expect(result).toMatchInlineSnapshot(` + "# Monitor Nightly Import in **sentry-mcp-evals** + + **Slug**: nightly-import + **ID**: 4509100000000001 + **Project**: cloudflare-mcp + **Status**: ok + **Owner**: the-goats + **Last Check-In**: 2025-04-14T02:00:13.000Z + **Next Check-In**: 2025-04-15T02:00:00.000Z + **URL**: [Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/) + + ## Schedule + + - **schedule**: ["crontab","0 2 * * *"] + - **schedule_type**: crontab + - **timezone**: UTC + - **checkin_margin**: 5 + - **max_runtime**: 30 + + ## Environments + + - production + - Status: ok + - Last check-in: 2025-04-14T02:00:13.000Z + - Next check-in: 2025-04-15T02:00:00.000Z + - staging + - Status: missed_checkin + - Last check-in: 2025-04-13T02:00:18.000Z + - Next check-in: 2025-04-14T02:00:00.000Z + + ## Recent Check-Ins + + - 2025-04-14T02:00:13.000Z: ok, 13.2s, production + - 2025-04-14T02:05:00.000Z: missed, staging + + ## Stats + + - 2025-04-14T02:00:00.000Z: ok=1, error=0, missed=0, timeout=0, unknown=0, duration=13.2 + - 2025-04-15T02:00:00.000Z: ok=0, error=0, missed=1, timeout=0, unknown=0, duration=0 + + ## Response Notes + + - Search issues from this monitor with \`search_issues\` query \`monitor.slug:nightly-import\`. + " + `); + }); + + it("rejects monitors outside the active project constraint", async () => { + await expect( + getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: "cloudflare-mcp", + monitorSlug: "nightly-import", + environment: null, + statsPeriod: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + { + ...context, + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "frontend", + }, + }, + ), + ).rejects.toThrow( + 'Monitor is outside the active project constraint. Expected project "frontend".', + ); + }); + + it("uses the active project constraint for monitor detail endpoints", async () => { + const paths: string[] = []; + const monitorResponse = { + id: "4509100000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "ok", + owner: null, + project: { + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + config: { + schedule_type: "crontab", + schedule: ["crontab", "0 2 * * *"], + }, + environments: [], + }; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json(monitorResponse); + }, + ), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/checkins/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json([]); + }, + ), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/stats/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json([]); + }, + ), + ); + + const result = await getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + { + ...context, + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + }, + }, + ); + + expect(result).toContain( + "# Monitor Nightly Import in **sentry-mcp-evals**", + ); + expect(paths).toEqual([ + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/", + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/checkins/", + "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/stats/", + ]); + }); + + it("uses StatsMixin time parameters for monitor stats", async () => { + let checkInsRequestUrl: string | null = null; + let statsRequestUrl: string | null = null; + const monitorResponse = { + id: "4509100000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "ok", + owner: null, + project: { + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + config: { + schedule_type: "crontab", + schedule: ["crontab", "0 2 * * *"], + }, + environments: [], + }; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + () => HttpResponse.json(monitorResponse), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/checkins/", + ({ request }) => { + checkInsRequestUrl = request.url; + return HttpResponse.json([]); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/stats/", + ({ request }) => { + statsRequestUrl = request.url; + return HttpResponse.json([]); + }, + ), + ); + + await getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: "production", + statsPeriod: null, + start: "2025-04-14T02:00:00.000Z", + end: "2025-04-14T03:00:00.000Z", + checkInLimit: 10, + includeStats: true, + rollupSeconds: 3600, + }, + context, + ); + + expect(checkInsRequestUrl).not.toBeNull(); + const checkInsParams = new URL(checkInsRequestUrl ?? "").searchParams; + expect(checkInsParams.get("environment")).toBe("production"); + expect(checkInsParams.get("start")).toBe("2025-04-14T02:00:00.000Z"); + expect(checkInsParams.get("end")).toBe("2025-04-14T03:00:00.000Z"); + expect(checkInsParams.get("statsPeriod")).toBeNull(); + + expect(statsRequestUrl).not.toBeNull(); + const params = new URL(statsRequestUrl ?? "").searchParams; + expect(params.get("environment")).toBe("production"); + expect(params.get("since")).toBe("1744596000"); + expect(params.get("until")).toBe("1744599600"); + expect(params.get("resolution")).toBe("3600s"); + expect(params.get("statsPeriod")).toBeNull(); + }); + + it("defaults blank statsPeriod to a 24h monitor window", async () => { + let checkInsRequestUrl: string | null = null; + let statsRequestUrl: string | null = null; + const monitorResponse = { + id: "4509100000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "ok", + owner: null, + project: { + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + config: { + schedule_type: "crontab", + schedule: ["crontab", "0 2 * * *"], + }, + environments: [], + }; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + () => HttpResponse.json(monitorResponse), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/checkins/", + ({ request }) => { + checkInsRequestUrl = request.url; + return HttpResponse.json([]); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/stats/", + ({ request }) => { + statsRequestUrl = request.url; + return HttpResponse.json([]); + }, + ), + ); + + await getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: " ", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + context, + ); + + expect(checkInsRequestUrl).not.toBeNull(); + const checkInsParams = new URL(checkInsRequestUrl ?? "").searchParams; + expect(checkInsParams.get("statsPeriod")).toBe("24h"); + + expect(statsRequestUrl).not.toBeNull(); + const statsParams = new URL(statsRequestUrl ?? "").searchParams; + expect(statsParams.get("statsPeriod")).toBeNull(); + expect(statsParams.get("since")).not.toBeNull(); + expect(statsParams.get("until")).not.toBeNull(); + }); + + it("encodes monitor slugs in web links", async () => { + const monitorResponse = { + id: "4509100000000002", + slug: "nightly/import 1", + name: "Nightly Import 1", + status: "ok", + owner: null, + project: { + id: "4509109104082945", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + config: { + schedule_type: "crontab", + schedule: ["crontab", "0 2 * * *"], + }, + environments: [], + }; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + () => HttpResponse.json(monitorResponse), + ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/checkins/", + () => HttpResponse.json([]), + ), + ); + + const result = await getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: false, + rollupSeconds: null, + }, + context, + ); + + expect(result).toContain( + "[Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/)", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts new file mode 100644 index 00000000..a32b845f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -0,0 +1,314 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { UserInputError } from "../../errors"; +import type { + Monitor, + MonitorCheckIn, + MonitorStat, +} from "../../api-client/types"; +import type { ServerContext } from "../../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; +import { isNumericId, validateSlugOrId } from "../../utils/slug-validation"; +import { + compactLines, + formatActor, + formatDate, + formatId, + formatUnknown, +} from "./support/api-formatting"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +function formatProject(monitor: Monitor): string | null { + if (!monitor.project) { + return null; + } + + return monitor.project.slug ?? monitor.project.name ?? null; +} + +function formatConfig(monitor: Monitor): string[] { + const config = monitor.config; + if (!config) { + return []; + } + + return [ + "schedule", + "schedule_type", + "timezone", + "checkin_margin", + "max_runtime", + ] + .filter((key) => config[key] !== undefined) + .map((key) => `- **${key}**: ${formatUnknown(config[key])}`); +} + +function formatCheckIn(checkIn: MonitorCheckIn): string { + const date = + formatDate(checkIn.dateCreated) ?? + formatDate(checkIn.dateUpdated) ?? + "unknown time"; + const duration = + checkIn.duration === undefined || checkIn.duration === null + ? "" + : `, ${checkIn.duration}s`; + const environment = checkIn.environment ? `, ${checkIn.environment}` : ""; + return `- ${date}: ${checkIn.status ?? "unknown"}${duration}${environment}`; +} + +function formatStat(stat: MonitorStat): string { + const timestamp = + formatDate(new Date(stat.ts * 1000).toISOString()) ?? String(stat.ts); + const parts = Object.entries(stat) + .filter(([key]) => key !== "ts") + .map(([key, value]) => `${key}=${formatUnknown(value)}`); + return `- ${timestamp}: ${parts.join(", ") || "no check-ins"}`; +} + +export default defineTool({ + name: "get_monitor_details", + skills: ["inspect"], + requiredScopes: ["org:read"], + description: [ + "Get details for a Sentry cron monitor.", + "", + "Use this tool when you need to:", + "- Inspect a monitor's schedule, status, owner, project, and environments", + "- Review recent check-ins for missed, failed, timeout, or OK runs", + "- Check monitor stats over a recent time range", + "", + "", + "get_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')", + "get_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', statsPeriod='7d')", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlugOrId: z + .string() + .toLowerCase() + .trim() + .superRefine(validateSlugOrId) + .nullable() + .default(null) + .describe( + "Optional project slug or numeric ID. Use this to disambiguate monitors with the same slug or to scope a monitor URL that includes a project segment.", + ), + monitorSlug: z.string().trim().min(1).describe("Monitor slug or GUID."), + environment: z + .string() + .trim() + .describe( + "Optional environment name to filter monitor environments, recent check-ins, and stats.", + ) + .nullable() + .default(null), + statsPeriod: z + .string() + .trim() + .describe( + "Relative time range, such as `24h`, `7d`, or `14d`. Defaults to `24h` when `start` and `end` are omitted.", + ) + .nullable() + .default(null), + start: z + .string() + .datetime() + .describe( + "Absolute start time. Must be provided with `end`; do not combine with `statsPeriod`.", + ) + .nullable() + .default(null), + end: z + .string() + .datetime() + .describe( + "Absolute end time. Must be provided with `start`; do not combine with `statsPeriod`.", + ) + .nullable() + .default(null), + checkInLimit: z + .number() + .int() + .positive() + .max(50) + .describe("Maximum number of recent check-ins to include.") + .default(10), + includeStats: z + .boolean() + .describe("Include aggregate check-in stats for the selected time range.") + .default(true), + rollupSeconds: z + .number() + .int() + .positive() + .describe( + "Optional stats bucket size in seconds. Omit this to let Sentry choose an appropriate rollup.", + ) + .nullable() + .default(null), + }, + 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); + setTag("monitor.slug", params.monitorSlug); + const requestedProjectSlugOrId = params.projectSlugOrId ?? undefined; + const projectSlugOrId = + requestedProjectSlugOrId ?? context.constraints.projectSlug ?? null; + if (projectSlugOrId) { + if (isNumericId(projectSlugOrId)) { + setTag("project.id", projectSlugOrId); + } else { + setTag("project.slug", projectSlugOrId); + } + } + if (requestedProjectSlugOrId && !isNumericId(requestedProjectSlugOrId)) { + assertProjectRefWithinConstraint({ + resourceLabel: "Monitor", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: requestedProjectSlugOrId }, + }); + } + if ( + requestedProjectSlugOrId && + isNumericId(requestedProjectSlugOrId) && + context.constraints.projectSlug + ) { + const scopedProject = await apiService.getProject({ + organizationSlug, + projectSlugOrId: context.constraints.projectSlug, + }); + if (String(scopedProject.id) !== requestedProjectSlugOrId) { + throw new UserInputError( + `Monitor is outside the active project constraint. Expected project "${context.constraints.projectSlug}".`, + ); + } + } + const start = params.start ?? undefined; + const end = params.end ?? undefined; + const hasAbsoluteTimeRange = start !== undefined || end !== undefined; + const requestedStatsPeriod = params.statsPeriod?.trim() || undefined; + const statsPeriod = hasAbsoluteTimeRange + ? undefined + : (requestedStatsPeriod ?? "24h"); + + const monitor = await apiService.getMonitorDetails({ + organizationSlug, + projectSlug: projectSlugOrId ?? undefined, + monitorSlug: params.monitorSlug, + environment: params.environment ?? undefined, + }); + assertProjectRefWithinConstraint({ + resourceLabel: "Monitor", + scopedProjectSlug: context.constraints.projectSlug, + project: monitor.project, + }); + + const project = formatProject(monitor); + const monitorUrl = apiService.getMonitorUrl( + organizationSlug, + monitor.slug, + project ?? undefined, + ); + + const [checkIns, stats] = await Promise.all([ + apiService.listMonitorCheckIns({ + organizationSlug, + projectSlug: projectSlugOrId ?? undefined, + monitorSlug: params.monitorSlug, + environment: params.environment ?? undefined, + statsPeriod, + start, + end, + limit: params.checkInLimit, + }), + params.includeStats + ? apiService.getMonitorStats({ + organizationSlug, + projectSlug: projectSlugOrId ?? undefined, + monitorSlug: params.monitorSlug, + environment: params.environment ?? undefined, + statsPeriod, + start, + end, + rollup: params.rollupSeconds ?? undefined, + }) + : Promise.resolve([]), + ]); + + const owner = monitor.owner ? formatActor(monitor.owner) : null; + const output = compactLines([ + `# Monitor ${monitor.name ?? monitor.slug} in **${organizationSlug}**`, + "", + `**Slug**: ${monitor.slug}`, + `**ID**: ${formatId(monitor.id)}`, + project ? `**Project**: ${project}` : null, + monitor.status ? `**Status**: ${monitor.status}` : null, + monitor.type ? `**Type**: ${monitor.type}` : null, + owner ? `**Owner**: ${owner}` : null, + formatDate(monitor.lastCheckIn) + ? `**Last Check-In**: ${formatDate(monitor.lastCheckIn)}` + : null, + formatDate(monitor.nextCheckIn) + ? `**Next Check-In**: ${formatDate(monitor.nextCheckIn)}` + : null, + `**URL**: [Open Monitor](${monitorUrl})`, + ]); + + const config = formatConfig(monitor); + if (config.length > 0) { + output.push("", "## Schedule", "", ...config); + } + + if (monitor.environments && monitor.environments.length > 0) { + output.push("", "## Environments", ""); + for (const environment of monitor.environments) { + output.push( + compactLines([ + `- ${environment.name ?? "unknown"}`, + environment.status ? ` - Status: ${environment.status}` : null, + formatDate(environment.lastCheckIn) + ? ` - Last check-in: ${formatDate(environment.lastCheckIn)}` + : null, + formatDate(environment.nextCheckIn) + ? ` - Next check-in: ${formatDate(environment.nextCheckIn)}` + : null, + ]).join("\n"), + ); + } + } + + output.push("", "## Recent Check-Ins", ""); + output.push( + checkIns.length === 0 + ? "No check-ins found in this time range." + : checkIns.slice(0, params.checkInLimit).map(formatCheckIn).join("\n"), + ); + + if (params.includeStats) { + output.push("", "## Stats", ""); + output.push( + stats.length === 0 + ? "No stats found in this time range." + : stats.slice(-10).map(formatStat).join("\n"), + ); + } + + output.push("", "## Response Notes", ""); + output.push( + `- Search issues from this monitor with \`search_issues\` query \`monitor.slug:${monitor.slug}\`.`, + ); + + return `${output.join("\n")}\n`; + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts index f0782ee7..c443ebb3 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts @@ -377,6 +377,20 @@ describe("resolveResourceParams", () => { }); }); + it("allows numeric project IDs in monitor URLs under a project constraint", () => { + expect( + resolveResourceParams({ + url: "https://my-org.sentry.io/crons/4509109104082945/my-monitor/", + projectSlug: "backend", + }), + ).toEqual({ + type: "monitor", + organizationSlug: "my-org", + projectSlugOrId: "4509109104082945", + monitorSlug: "my-monitor", + }); + }); + it("parses release URL", () => { expect( resolveResourceParams({ @@ -705,14 +719,34 @@ describe("resolveResourceParams", () => { }); }); - it("throws for unsupported explicit resourceType (monitor)", () => { - expect(() => + it("accepts monitor as an explicit resourceType", () => { + expect( resolveResourceParams({ resourceType: "monitor", organizationSlug: "my-org", - resourceId: "something", + resourceId: "daily-backup", + }), + ).toEqual({ + type: "monitor", + organizationSlug: "my-org", + monitorSlug: "daily-backup", + }); + }); + + it("preserves scoped project for explicit monitor resourceType", () => { + expect( + resolveResourceParams({ + resourceType: "monitor", + organizationSlug: "my-org", + projectSlug: "backend", + resourceId: "daily-backup", }), - ).toThrow("Invalid resourceType: monitor"); + ).toEqual({ + type: "monitor", + organizationSlug: "my-org", + monitorSlug: "daily-backup", + projectSlugOrId: "backend", + }); }); it("throws when span resourceId is malformed", () => { diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts index 56ee1094..f8d4e925 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts @@ -48,6 +48,7 @@ function callHandler(params: { | "span" | "breadcrumbs" | "replay" + | "monitor" | "snapshot" | "snapshotImage"; resourceId?: string; @@ -56,6 +57,62 @@ function callHandler(params: { return getSentryResource.handler(params, baseContext); } +function mockMonitorResource({ + apiBaseUrl = "https://sentry.io", + organizationSlug, + monitorSlug, + projectPath, + projectSlug = "backend", +}: { + apiBaseUrl?: string; + organizationSlug: string; + monitorSlug: string; + projectPath?: string; + projectSlug?: string; +}) { + const organizationMonitorPath = `${apiBaseUrl}/api/0/organizations/${organizationSlug}/monitors/${monitorSlug}/`; + const projectMonitorPath = `${apiBaseUrl}/api/0/projects/${organizationSlug}/${projectPath ?? projectSlug}/monitors/${monitorSlug}/`; + const projectId = + projectPath && /^\d+$/.test(projectPath) ? projectPath : "456"; + const buildMonitor = (slug: string) => ({ + id: "123", + slug: monitorSlug, + name: monitorSlug, + status: "ok", + project: { + id: projectId, + slug, + name: slug, + }, + config: { + schedule: ["crontab", "0 0 * * *"], + }, + }); + return [ + http.get(organizationMonitorPath, () => + HttpResponse.json(buildMonitor("backend")), + ), + http.get( + `${apiBaseUrl}/api/0/projects/${organizationSlug}/${projectSlug}/`, + () => + HttpResponse.json({ + id: projectId, + slug: projectSlug, + name: projectSlug, + }), + ), + http.get(`${organizationMonitorPath}checkins/`, () => + HttpResponse.json([]), + ), + http.get(`${organizationMonitorPath}stats/`, () => HttpResponse.json([])), + http.get(projectMonitorPath, () => + HttpResponse.json(buildMonitor(projectSlug)), + ), + http.get(`${projectMonitorPath}checkins/`, () => HttpResponse.json([])), + http.get(`${projectMonitorPath}stats/`, () => HttpResponse.json([])), + ]; +} + describe("get_sentry_resource", () => { beforeEach(() => { Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); @@ -447,33 +504,148 @@ describe("get_sentry_resource", () => { expect(result).toContain("Clicked submit order"); }); - it("returns guidance for monitor URL (simple slug)", async () => { + it("dispatches monitor URL with a simple slug to get_monitor_details", async () => { + mswServer.use( + ...mockMonitorResource({ + organizationSlug: "my-org", + monitorSlug: "daily-backup", + }), + ); + const result = await callHandler({ url: "https://my-org.sentry.io/crons/daily-backup/", }); - expect(result).toMatchInlineSnapshot(` - "# Cron Monitor Detected - **Organization**: my-org - **Monitor**: daily-backup - Cron monitor support is coming soon. In the meantime: - - **View in Sentry**: [Open Monitor](https://my-org.sentry.io/crons/daily-backup/) - - **Search issues**: Use \`search_issues\` with query \`monitor.slug:daily-backup\` to find issues from this monitor" - `); + expect(result).toContain("# Monitor daily-backup in **my-org**"); + expect(result).toContain( + "[Open Monitor](https://my-org.sentry.io/crons/backend/daily-backup/)", + ); }); - it("returns guidance for monitor URL with project/slug path", async () => { + it("dispatches monitor URL with project/slug path to get_monitor_details", async () => { + mswServer.use( + ...mockMonitorResource({ + organizationSlug: "my-org", + monitorSlug: "my-monitor", + projectSlug: "my-project", + }), + ); + const result = await callHandler({ url: "https://my-org.sentry.io/crons/my-project/my-monitor/", }); - expect(result).toMatchInlineSnapshot(` - "# Cron Monitor Detected - **Organization**: my-org - **Monitor**: my-monitor - **Project**: my-project - Cron monitor support is coming soon. In the meantime: - - **View in Sentry**: [Open Monitor](https://my-org.sentry.io/crons/my-project/my-monitor/) - - **Search issues**: Use \`search_issues\` with query \`monitor.slug:my-monitor\` to find issues from this monitor" - `); + expect(result).toContain("# Monitor my-monitor in **my-org**"); + expect(result).toContain( + "[Open Monitor](https://my-org.sentry.io/crons/my-project/my-monitor/)", + ); + }); + + it("dispatches monitor URL with numeric project ID under an active project constraint", async () => { + mswServer.use( + ...mockMonitorResource({ + organizationSlug: "my-org", + monitorSlug: "my-monitor", + projectPath: "4509109104082945", + projectSlug: "backend", + }), + ); + + const result = await getSentryResource.handler( + { + url: "https://my-org.sentry.io/crons/4509109104082945/my-monitor/", + }, + { + ...baseContext, + constraints: { + ...baseContext.constraints, + projectSlug: "backend", + }, + }, + ); + + expect(result).toContain("# Monitor my-monitor in **my-org**"); + expect(result).toContain( + "[Open Monitor](https://my-org.sentry.io/crons/backend/my-monitor/)", + ); + }); + + it("rejects numeric monitor project IDs outside the active project constraint before monitor reads", async () => { + const paths: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/my-org/frontend/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json({ + id: "999", + slug: "frontend", + name: "frontend", + }); + }, + ), + http.get( + "https://sentry.io/api/0/projects/my-org/4509109104082945/monitors/my-monitor/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return HttpResponse.json({ + id: "123", + slug: "my-monitor", + project: { + id: "4509109104082945", + slug: "backend", + name: "backend", + }, + }); + }, + ), + ); + + await expect( + getSentryResource.handler( + { + url: "https://my-org.sentry.io/crons/4509109104082945/my-monitor/", + }, + { + ...baseContext, + constraints: { + ...baseContext.constraints, + projectSlug: "frontend", + }, + }, + ), + ).rejects.toThrow( + 'Monitor is outside the active project constraint. Expected project "frontend".', + ); + expect(paths).toEqual(["/api/0/projects/my-org/frontend/"]); + }); + + it("dispatches explicit monitor resourceType under an active project constraint", async () => { + mswServer.use( + ...mockMonitorResource({ + organizationSlug: "my-org", + monitorSlug: "my-monitor", + projectSlug: "backend", + }), + ); + + const result = await getSentryResource.handler( + { + resourceType: "monitor", + resourceId: "my-monitor", + organizationSlug: "my-org", + }, + { + ...baseContext, + constraints: { + ...baseContext.constraints, + projectSlug: "backend", + }, + }, + ); + + expect(result).toContain("# Monitor my-monitor in **my-org**"); + expect(result).toContain( + "[Open Monitor](https://my-org.sentry.io/crons/backend/my-monitor/)", + ); }); it("returns guidance for release URL", async () => { @@ -513,6 +685,14 @@ describe("get_sentry_resource", () => { }); it("uses configured host and protocol for self-hosted monitor URL", async () => { + mswServer.use( + ...mockMonitorResource({ + apiBaseUrl: "http://sentry.internal:9000", + organizationSlug: "my-org", + monitorSlug: "daily-backup", + }), + ); + const result = await getSentryResource.handler( { resourceType: undefined, @@ -527,7 +707,7 @@ describe("get_sentry_resource", () => { }, ); expect(result).toContain( - "[Open Monitor](http://sentry.internal:9000/organizations/my-org/crons/daily-backup/)", + "[Open Monitor](http://sentry.internal:9000/organizations/my-org/crons/backend/daily-backup/)", ); }); diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts index 4c0be6e7..5be9f0dd 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts @@ -18,12 +18,14 @@ import { } from "../../internal/url-scope"; import { ParamOrganizationSlug } from "../../schema"; import type { ServerContext } from "../../types"; +import { isNumericId } from "../../utils/slug-validation"; import { fetchSnapshotImage, fetchSnapshotSummary, } from "../support/snapshots/handlers"; import getAIConversationDetails from "./get-ai-conversation-details"; import getIssueDetails from "./get-issue-details"; +import getMonitorDetails from "./get-monitor-details"; import getProfileDetails from "./get-profile-details"; import getReplayDetails from "./get-replay-details"; import getTraceDetails from "./get-trace-details"; @@ -37,13 +39,14 @@ export const FULLY_SUPPORTED_TYPES = [ "ai_conversation", "breadcrumbs", "replay", + "monitor", "snapshot", "snapshotImage", ] as const; export type FullySupportedType = (typeof FULLY_SUPPORTED_TYPES)[number]; /** Recognized from URLs but not yet fully supported -- return guidance messages. */ -export type RecognizedType = "monitor" | "release"; +export type RecognizedType = "release"; /** All resource types. */ export type ResolvedResourceType = @@ -175,6 +178,14 @@ export function resolveResourceParams(params: { replayId: resourceId, }; + case "monitor": + return { + type: "monitor", + organizationSlug, + monitorSlug: resourceId, + projectSlugOrId: params.projectSlug ?? undefined, + }; + case "snapshot": return { type: "snapshot", @@ -362,11 +373,13 @@ function resolveFromParsedUrl( organizationSlug, monitorSlug: parsed.monitorSlug, projectSlugOrId: parsed.projectSlugOrId - ? resolveScopedProjectSlug({ - resourceLabel: "Monitor", - scopedProjectSlug: params.projectSlug, - urlProjectSlug: parsed.projectSlugOrId, - }) + ? isNumericId(parsed.projectSlugOrId) + ? parsed.projectSlugOrId + : resolveScopedProjectSlug({ + resourceLabel: "Monitor", + scopedProjectSlug: params.projectSlug, + urlProjectSlug: parsed.projectSlugOrId, + }) : undefined, }; @@ -429,6 +442,18 @@ function parseSpanResourceId(resourceId: string): { }; } +function assertCatalogToolAvailable( + context: ServerContext, + toolName: string, + resourceLabel: string, +) { + if (context.availableToolNames && !context.availableToolNames.has(toolName)) { + throw new UserInputError( + `${resourceLabel} resources require the inspect skill. Enable inspect tools or call ${toolName} in a session where it is available.`, + ); + } +} + function generateUnsupportedResourceMessage( resolved: ResolvedResourceParams, apiService: SentryApiService, @@ -439,33 +464,6 @@ function generateUnsupportedResourceMessage( const { type, organizationSlug } = resolved; switch (type) { - case "monitor": { - // Include projectSlugOrId in URL when present - const monitorPath = resolved.projectSlugOrId - ? `${resolved.projectSlugOrId}/${resolved.monitorSlug}` - : (resolved.monitorSlug ?? ""); - const monitorUrl = apiService.getMonitorUrl( - organizationSlug, - monitorPath, - ); - return [ - "# Cron Monitor Detected", - "", - `**Organization**: ${organizationSlug}`, - `**Monitor**: ${resolved.monitorSlug}`, - resolved.projectSlugOrId - ? `**Project**: ${resolved.projectSlugOrId}` - : "", - "", - "Cron monitor support is coming soon. In the meantime:", - "", - `- **View in Sentry**: [Open Monitor](${monitorUrl})`, - `- **Search issues**: Use \`search_issues\` with query \`monitor.slug:${resolved.monitorSlug}\` to find issues from this monitor`, - ] - .filter(Boolean) - .join("\n"); - } - case "release": { const releaseUrl = apiService.getReleaseUrl( organizationSlug, @@ -526,7 +524,7 @@ export default defineTool({ "Fetch a Sentry resource by URL, or by resourceType plus resourceId.", "Pass a Sentry URL directly when possible; the resource type is auto-detected.", "", - "Supports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.", + "Supports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.", "Trace lookups return a condensed overview by default.", "", "AI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.", @@ -537,6 +535,7 @@ export default defineTool({ "", "Resource IDs:", "- span: :", + "- monitor: ", "- snapshot: ", "- snapshotImage: :", "", @@ -569,12 +568,13 @@ export default defineTool({ "ai_conversation", "breadcrumbs", "replay", + "monitor", "snapshot", "snapshotImage", ]) .optional() .describe( - "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`.", + "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`.", ), resourceId: z @@ -582,7 +582,7 @@ export default defineTool({ .trim() .optional() .describe( - "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL.", + "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL.", ), organizationSlug: ParamOrganizationSlug.optional(), @@ -609,7 +609,7 @@ export default defineTool({ getActiveSpan()?.setAttribute("app.resource.type", resolved.type); // Recognized but not yet fully supported types return guidance messages - if (resolved.type === "monitor" || resolved.type === "release") { + if (resolved.type === "release") { const apiService = apiServiceFromContext(context, { regionUrl: context.constraints.regionUrl ?? undefined, }); @@ -714,6 +714,25 @@ export default defineTool({ context, ); + case "monitor": + assertCatalogToolAvailable(context, "get_monitor_details", "Monitor"); + return getMonitorDetails.handler( + { + organizationSlug: resolved.organizationSlug, + projectSlugOrId: resolved.projectSlugOrId ?? null, + monitorSlug: resolved.monitorSlug!, + regionUrl: context.constraints.regionUrl ?? null, + environment: null, + statsPeriod: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + context, + ); + case "profile": return getProfileDetails.handler( { diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index d553f1e2..60f5c6d7 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 findMonitors from "./find-monitors"; +import getMonitorDetails from "./get-monitor-details"; import getIssueDetails from "./get-issue-details"; import getIssueActivity from "./get-issue-activity"; import getIssueTagValues from "./get-issue-tag-values"; @@ -48,6 +50,8 @@ const catalogTools = { find_projects: findProjects, find_releases: findReleases, get_release_details: getReleaseDetails, + find_monitors: findMonitors, + get_monitor_details: getMonitorDetails, get_issue_details: getIssueDetails, get_issue_activity: getIssueActivity, get_issue_tag_values: getIssueTagValues, diff --git a/packages/mcp-core/src/utils/url-utils.test.ts b/packages/mcp-core/src/utils/url-utils.test.ts index 8710156f..b8087a92 100644 --- a/packages/mcp-core/src/utils/url-utils.test.ts +++ b/packages/mcp-core/src/utils/url-utils.test.ts @@ -9,6 +9,7 @@ import { getReplaysSearchUrl, getReplayUrl, getReleaseUrl, + getMonitorUrl, getTraceUrl, getEventsExplorerUrl, getTraceMetricsExploreUrl, @@ -177,6 +178,34 @@ describe("url-utils", () => { }); }); + describe("getMonitorUrl", () => { + it("should encode project and monitor path segments independently", () => { + const result = getMonitorUrl( + "sentry.io", + "myorg", + "daily/import 1", + "https", + "backend", + ); + expect(result).toBe( + "https://myorg.sentry.io/crons/backend/daily%2Fimport%201/", + ); + }); + + it("should use self-hosted organization paths", () => { + const result = getMonitorUrl( + "sentry.internal:9000", + "myorg", + "daily-backup", + "http", + "backend", + ); + expect(result).toBe( + "http://sentry.internal:9000/organizations/myorg/crons/backend/daily-backup/", + ); + }); + }); + describe("getPreprodSnapshotUrl", () => { it("should handle regional URLs correctly for SaaS", () => { const result = getPreprodSnapshotUrl("us.sentry.io", "myorg", "12"); diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index 4a917634..45c4c511 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -368,7 +368,8 @@ export function getPreprodSnapshotUrl( * Generates a Sentry cron monitor URL. * @param host The Sentry host (may include regional subdomain for API access) * @param organizationSlug Organization identifier - * @param monitorSlug Monitor slug, optionally prefixed with project slug (e.g. "my-project/my-monitor") + * @param monitorSlug Monitor slug + * @param projectSlug Optional project slug to disambiguate monitors with the same slug * @returns The complete monitor URL */ export function getMonitorUrl( @@ -376,11 +377,15 @@ export function getMonitorUrl( organizationSlug: string, monitorSlug: string, protocol: SentryProtocol = "https", + projectSlug?: string, ): string { + const monitorPath = (projectSlug ? [projectSlug, monitorSlug] : [monitorSlug]) + .map((segment) => encodeURIComponent(segment)) + .join("/"); return getSentryWebBaseUrl( host, organizationSlug, - `/crons/${monitorSlug}/`, + `/crons/${monitorPath}/`, protocol, ); } diff --git a/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json b/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json new file mode 100644 index 00000000..ef63f1bb --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json @@ -0,0 +1,34 @@ +[ + { + "id": "11111111-1111-4111-8111-111111111111", + "environment": "production", + "status": "ok", + "duration": 13.2, + "dateCreated": "2025-04-14T02:00:13.000Z", + "dateAdded": "2025-04-14T02:00:13.000Z", + "dateUpdated": "2025-04-14T02:00:26.000Z", + "dateInProgress": "2025-04-14T02:00:00.000Z", + "dateClock": "2025-04-14T02:00:00.000Z", + "expectedTime": "2025-04-14T02:00:00.000Z", + "monitorConfig": { + "schedule": ["crontab", "0 2 * * *"], + "schedule_type": "crontab" + } + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "environment": "staging", + "status": "missed", + "duration": null, + "dateCreated": "2025-04-14T02:05:00.000Z", + "dateAdded": "2025-04-14T02:05:00.000Z", + "dateUpdated": "2025-04-14T02:05:00.000Z", + "dateInProgress": null, + "dateClock": "2025-04-14T02:00:00.000Z", + "expectedTime": "2025-04-14T02:00:00.000Z", + "monitorConfig": { + "schedule": ["crontab", "0 2 * * *"], + "schedule_type": "crontab" + } + } +] diff --git a/packages/mcp-server-mocks/src/fixtures/monitor-stats.json b/packages/mcp-server-mocks/src/fixtures/monitor-stats.json new file mode 100644 index 00000000..b87fe867 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/monitor-stats.json @@ -0,0 +1,20 @@ +[ + { + "ts": 1744596000, + "ok": 1, + "error": 0, + "missed": 0, + "timeout": 0, + "unknown": 0, + "duration": 13.2 + }, + { + "ts": 1744682400, + "ok": 0, + "error": 0, + "missed": 1, + "timeout": 0, + "unknown": 0, + "duration": 0 + } +] diff --git a/packages/mcp-server-mocks/src/fixtures/monitor.json b/packages/mcp-server-mocks/src/fixtures/monitor.json new file mode 100644 index 00000000..4adc39e2 --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/monitor.json @@ -0,0 +1,52 @@ +{ + "id": "4509100000000001", + "slug": "nightly-import", + "name": "Nightly Import", + "status": "ok", + "isMuted": false, + "isUpserting": false, + "project": { + "id": "4509062593708032", + "slug": "cloudflare-mcp", + "name": "cloudflare-mcp" + }, + "owner": { + "type": "team", + "id": "4509109078196224", + "name": "the-goats" + }, + "dateCreated": "2025-04-10T12:00:00.000Z", + "lastCheckIn": "2025-04-14T02:00:13.000Z", + "nextCheckIn": "2025-04-15T02:00:00.000Z", + "config": { + "schedule": ["crontab", "0 2 * * *"], + "schedule_type": "crontab", + "timezone": "UTC", + "checkin_margin": 5, + "max_runtime": 30 + }, + "environments": [ + { + "id": "1", + "name": "production", + "status": "ok", + "isMuted": false, + "dateCreated": "2025-04-10T12:00:00.000Z", + "lastCheckIn": "2025-04-14T02:00:13.000Z", + "nextCheckIn": "2025-04-15T02:00:00.000Z", + "nextCheckInLatest": "2025-04-15T02:05:00.000Z", + "activeIncident": null + }, + { + "id": "2", + "name": "staging", + "status": "missed_checkin", + "isMuted": false, + "dateCreated": "2025-04-10T12:00:00.000Z", + "lastCheckIn": "2025-04-13T02:00:18.000Z", + "nextCheckIn": "2025-04-14T02:00:00.000Z", + "nextCheckInLatest": "2025-04-14T02:05:00.000Z", + "activeIncident": null + } + ] +} diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index c027e369..cb4504a9 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -95,6 +95,13 @@ import releaseCommitsFixture from "./fixtures/release-commits.json" with { import releaseDeploysFixture from "./fixtures/release-deploys.json" with { type: "json", }; +import monitorFixture from "./fixtures/monitor.json" with { type: "json" }; +import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with { + type: "json", +}; +import monitorStatsFixture from "./fixtures/monitor-stats.json" with { + type: "json", +}; import tagsFixture from "./fixtures/tags.json" with { type: "json" }; import teamFixture from "./fixtures/team.json" with { type: "json" }; import traceEventFixture from "./fixtures/trace-event.json" with { @@ -856,6 +863,41 @@ export const restHandlers = buildHandlers([ path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/releases/8ce89484-0fec-4913-a2cd-e8e2d41dee36/commits/", fetch: () => HttpResponse.json(releaseCommitsFixture), }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/monitors/", + fetch: () => HttpResponse.json([monitorFixture]), + }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + fetch: () => HttpResponse.json(monitorFixture), + }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/checkins/", + fetch: () => HttpResponse.json(monitorCheckInsFixture), + }, + { + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/stats/", + fetch: () => HttpResponse.json(monitorStatsFixture), + }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/", + fetch: () => HttpResponse.json(monitorFixture), + }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/checkins/", + fetch: () => HttpResponse.json(monitorCheckInsFixture), + }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/stats/", + fetch: () => HttpResponse.json(monitorStatsFixture), + }, { method: "get", path: "/api/0/organizations/sentry-mcp-evals/tags/", From 1bb7678646d1df7991010b253336a45289050ea6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 5 Jun 2026 18:35:17 +0200 Subject: [PATCH 2/6] fix(monitors): Tighten catalog metadata Use the project read scope for monitor details and only advertise monitor resources when the inspect monitor tool is available. This keeps generated catalog guidance aligned with the exposed tool surface. Co-Authored-By: Codex --- packages/mcp-core/src/server.test.ts | 20 +++++++++++ packages/mcp-core/src/skillDefinitions.json | 6 ++-- packages/mcp-core/src/skills.test.ts | 17 ++++++++++ packages/mcp-core/src/toolDefinitions.json | 6 ++-- .../tools/catalog/get-monitor-details.test.ts | 6 ++++ .../src/tools/catalog/get-monitor-details.ts | 2 +- .../src/tools/catalog/get-sentry-resource.ts | 33 ++++++++++++++----- 7 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index a9e094d5..0c2a7b8d 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -702,6 +702,26 @@ describe("buildServer", () => { ); }); + it("does not advertise monitor resources when inspect tools are unavailable", async () => { + const server = buildServer({ + context: { + ...baseContext, + grantedSkills: new Set(["triage"]), + }, + }); + + const registeredTools = await listRegisteredTools(server); + const getSentryResource = registeredTools.find( + (tool) => tool.name === "get_sentry_resource", + ); + + expect(getSentryResource?.description).toContain("replays"); + expect(getSentryResource?.description).not.toContain("monitors"); + expect(getSentryResource?.description).not.toContain( + "- monitor: ", + ); + }); + it("does not recommend unavailable catalog tools in generated runtime guidance", async () => { const server = buildServer({ context: { diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 6ca0479b..b570e2c1 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -65,7 +65,7 @@ { "name": "get_monitor_details", "description": "Get details for a Sentry cron monitor.\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', statsPeriod='7d')\n", - "requiredScopes": ["org:read"] + "requiredScopes": ["project:read"] }, { "name": "get_profile", @@ -164,7 +164,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, { @@ -264,7 +264,7 @@ }, { "name": "get_sentry_resource", - "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- monitor: \n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", + "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- span: :\n- snapshot: \n- snapshotImage: :\n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='span', organizationSlug='my-org', resourceId=':')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, { diff --git a/packages/mcp-core/src/skills.test.ts b/packages/mcp-core/src/skills.test.ts index 8fa18517..c7eedc0e 100644 --- a/packages/mcp-core/src/skills.test.ts +++ b/packages/mcp-core/src/skills.test.ts @@ -174,5 +174,22 @@ describe("skills module", () => { expect(inspectToolNames).toContain("get_snapshot_image"); expect(triageToolNames).not.toContain("get_snapshot_image"); }); + + it("omits monitor resource guidance when the inspect skill is not enabled", () => { + const inspectDescription = getGeneratedSkillToolDescription( + "inspect", + "get_sentry_resource", + ); + const triageDescription = getGeneratedSkillToolDescription( + "triage", + "get_sentry_resource", + ); + + expect(inspectDescription).toContain("monitors"); + expect(inspectDescription).toContain("- monitor: "); + + expect(triageDescription).not.toContain("monitors"); + expect(triageDescription).not.toContain("- monitor: "); + }); }); }); diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 4119c40b..43add1d0 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -989,7 +989,7 @@ "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, - "requiredScopes": ["org:read"], + "requiredScopes": ["project:read"], "skills": ["inspect"], "surface": "catalog" }, @@ -1255,11 +1255,11 @@ "snapshot", "snapshotImage" ], - "description": "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`." + "description": "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug only when inspect monitor tools are available, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`." }, "resourceId": { "type": "string", - "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL." + "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug when inspect monitor tools are available, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL." }, "organizationSlug": { "type": "string", diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 4ae21521..d2f4ec4c 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -376,4 +376,10 @@ describe("get_monitor_details", () => { "[Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/)", ); }); + + describe("tool definition", () => { + it("requires the project read scope used by the backend monitor endpoints", () => { + expect(getMonitorDetails.requiredScopes).toEqual(["project:read"]); + }); + }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index a32b845f..7b904538 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -70,7 +70,7 @@ function formatStat(stat: MonitorStat): string { export default defineTool({ name: "get_monitor_details", skills: ["inspect"], - requiredScopes: ["org:read"], + requiredScopes: ["project:read"], description: [ "Get details for a Sentry cron monitor.", "", diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts index 5be9f0dd..83ef29c2 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts @@ -447,13 +447,20 @@ function assertCatalogToolAvailable( toolName: string, resourceLabel: string, ) { - if (context.availableToolNames && !context.availableToolNames.has(toolName)) { + if (!isToolAvailable(toolName, context.availableToolNames)) { throw new UserInputError( `${resourceLabel} resources require the inspect skill. Enable inspect tools or call ${toolName} in a session where it is available.`, ); } } +function isToolAvailable( + toolName: string, + availableToolNames?: ReadonlySet, +): boolean { + return !availableToolNames || availableToolNames.has(toolName); +} + function generateUnsupportedResourceMessage( resolved: ResolvedResourceParams, apiService: SentryApiService, @@ -504,6 +511,10 @@ export default defineTool({ requiredScopes: ["event:read", "project:read"], description: ({ experimentalMode, availableToolNames, directToolNames }) => { + const monitorResourcesAvailable = isToolAvailable( + "get_monitor_details", + availableToolNames, + ); const fullResolutionInstruction = formatToolCallInstruction({ toolName: "get_snapshot_image", arguments: { @@ -519,12 +530,21 @@ export default defineTool({ "Full-resolution snapshot image bytes are not available in this session", purpose: "for full-resolution image bytes", }); + const supportedResources = monitorResourcesAvailable + ? "issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images." + : "issues, events, traces, spans, AI conversations, breadcrumbs, replays, preprod snapshots, and snapshot images."; + const resourceIds = [ + "- span: :", + ...(monitorResourcesAvailable ? ["- monitor: "] : []), + "- snapshot: ", + "- snapshotImage: :", + ]; return [ "Fetch a Sentry resource by URL, or by resourceType plus resourceId.", "Pass a Sentry URL directly when possible; the resource type is auto-detected.", "", - "Supports issues, events, traces, spans, AI conversations, breadcrumbs, replays, monitors, preprod snapshots, and snapshot images.", + `Supports ${supportedResources}`, "Trace lookups return a condensed overview by default.", "", "AI Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID to fetch all spans for that conversation. To discover or list conversation IDs, use search_events with dataset='spans' and query='has:gen_ai.conversation.id'. Conversations are NOT issues — do not use search_issues for conversation queries.", @@ -534,10 +554,7 @@ export default defineTool({ `- With ?selectedSnapshot=: returns the image preview and metadata. ${fullResolutionInstruction}.`, "", "Resource IDs:", - "- span: :", - "- monitor: ", - "- snapshot: ", - "- snapshotImage: :", + ...resourceIds, "", "", "get_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')", @@ -574,7 +591,7 @@ export default defineTool({ ]) .optional() .describe( - "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`.", + "Resource type. With a URL, can override the auto-detected type for breadcrumbs on an issue/event URL or for `trace` on a span-focused trace URL. Use `monitor` with a monitor slug only when inspect monitor tools are available, `snapshot` with a snapshot artifact ID, or `snapshotImage` with `:`.", ), resourceId: z @@ -582,7 +599,7 @@ export default defineTool({ .trim() .optional() .describe( - "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL.", + "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, trace ID, AI conversation ID, replay ID, monitor slug when inspect monitor tools are available, snapshot artifact ID, `:` for snapshot image resources, or `traceId:spanId` for span resources. Required when not using a URL.", ), organizationSlug: ParamOrganizationSlug.optional(), From 1fcc4fa1d16b6e7c5049b87d434a0b581c989751 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 6 Jun 2026 00:27:54 +0200 Subject: [PATCH 3/6] fix(monitors): Validate absolute time ranges Reject monitor detail requests that provide only one side of an absolute time range. This returns a clear local input error instead of forwarding partial start or end parameters to Sentry. Co-Authored-By: Codex --- .../tools/catalog/get-monitor-details.test.ts | 21 +++++++++++++++++++ .../src/tools/catalog/get-monitor-details.ts | 3 +++ 2 files changed, 24 insertions(+) diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index d2f4ec4c..0152a3ee 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -257,6 +257,27 @@ describe("get_monitor_details", () => { expect(params.get("statsPeriod")).toBeNull(); }); + it("rejects absolute monitor time ranges missing an end", async () => { + await expect( + getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: null, + start: "2025-04-14T02:00:00.000Z", + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + context, + ), + ).rejects.toThrow("`start` and `end` must be provided together."); + }); + it("defaults blank statsPeriod to a 24h monitor window", async () => { let checkInsRequestUrl: string | null = null; let statsRequestUrl: string | null = null; diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index 7b904538..305ef542 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -196,6 +196,9 @@ export default defineTool({ } const start = params.start ?? undefined; const end = params.end ?? undefined; + if ((start && !end) || (!start && end)) { + throw new UserInputError("`start` and `end` must be provided together."); + } const hasAbsoluteTimeRange = start !== undefined || end !== undefined; const requestedStatsPeriod = params.statsPeriod?.trim() || undefined; const statsPeriod = hasAbsoluteTimeRange From 8627fc251d0fdbb673267a5f1daf34402a7b810d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 14:31:46 -0700 Subject: [PATCH 4/6] fix(monitors): Enforce catalog tool constraints Reject contradictory monitor time ranges and ensure monitor listing honors active project constraints. Add request-shape coverage for monitor list filters and stats omission when stats are disabled. Co-Authored-By: Codex --- packages/mcp-core/src/skills.test.ts | 12 +++ .../src/tools/catalog/find-monitors.test.ts | 95 +++++++++++++++++++ .../src/tools/catalog/find-monitors.ts | 21 +++- .../tools/catalog/get-monitor-details.test.ts | 32 +++++++ .../src/tools/catalog/get-monitor-details.ts | 5 + 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/packages/mcp-core/src/skills.test.ts b/packages/mcp-core/src/skills.test.ts index c7eedc0e..8b42820d 100644 --- a/packages/mcp-core/src/skills.test.ts +++ b/packages/mcp-core/src/skills.test.ts @@ -18,6 +18,18 @@ function getGeneratedSkillToolNames(skillId: string) { return new Set(skill?.tools?.map((definition) => definition.name) ?? []); } +function getGeneratedSkillToolDescription(skillId: string, toolName: string) { + const skill = skillDefinitions.find( + (definition) => definition.id === skillId, + ); + expect(skill).toBeDefined(); + + const tool = skill?.tools?.find((definition) => definition.name === toolName); + expect(tool).toBeDefined(); + + return tool?.description ?? ""; +} + describe("skills module", () => { describe("SKILLS registry", () => { it("has all expected skills", () => { diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.test.ts b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts index ed7b9a2e..ee550c72 100644 --- a/packages/mcp-core/src/tools/catalog/find-monitors.test.ts +++ b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts @@ -91,6 +91,101 @@ describe("find_monitors", () => { expect(params.get("project")).toBeNull(); }); + it("sends monitor list filters to Sentry", async () => { + let requestUrl: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json([]); + }, + ), + ); + + await findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "backend", + environment: "production", + owner: "team:123", + query: "billing", + limit: 25, + }, + context, + ); + + expect(requestUrl).not.toBeNull(); + const params = new URL(requestUrl ?? "").searchParams; + expect(params.get("projectSlug")).toBe("backend"); + expect(params.get("environment")).toBe("production"); + expect(params.get("owner")).toBe("team:123"); + expect(params.get("query")).toBe("billing"); + expect(params.get("per_page")).toBe("25"); + }); + + it("uses the active project constraint as the monitor list project", async () => { + let requestUrl: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/", + ({ request }) => { + requestUrl = request.url; + return HttpResponse.json([]); + }, + ), + ); + + await findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "all", + environment: null, + owner: null, + query: null, + limit: 10, + }, + { + ...context, + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "backend", + }, + }, + ); + + expect(requestUrl).not.toBeNull(); + const params = new URL(requestUrl ?? "").searchParams; + expect(params.get("projectSlug")).toBe("backend"); + }); + + it("rejects monitor list projects outside the active project constraint", async () => { + await expect( + findMonitors.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "backend", + environment: null, + owner: null, + query: null, + limit: 10, + }, + { + ...context, + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "frontend", + }, + }, + ), + ).rejects.toThrow( + 'Monitor list is outside the active project constraint. Expected project "frontend".', + ); + }); + it("encodes monitor slugs in web links", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.ts b/packages/mcp-core/src/tools/catalog/find-monitors.ts index 40a468c1..35045bf6 100644 --- a/packages/mcp-core/src/tools/catalog/find-monitors.ts +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -16,6 +16,7 @@ import { formatId, formatUnknown, } from "./support/api-formatting"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; function formatProject(monitor: Monitor): string | null { if (!monitor.project) { @@ -141,13 +142,25 @@ export default defineTool({ }); const organizationSlug = params.organizationSlug; setTag("organization.slug", organizationSlug); + const requestedProjectSlug = + params.projectSlug && params.projectSlug !== "all" + ? params.projectSlug + : undefined; + if (requestedProjectSlug) { + assertProjectRefWithinConstraint({ + resourceLabel: "Monitor list", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: requestedProjectSlug }, + }); + } + const projectSlug = context.constraints.projectSlug ?? requestedProjectSlug; + if (projectSlug) { + setTag("project.slug", projectSlug); + } const monitors = await apiService.listMonitors({ organizationSlug, - projectSlug: - params.projectSlug && params.projectSlug !== "all" - ? params.projectSlug - : undefined, + projectSlug, environment: params.environment ?? undefined, owner: params.owner ?? undefined, query: params.query ?? undefined, diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 0152a3ee..92b8a450 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -278,6 +278,29 @@ describe("get_monitor_details", () => { ).rejects.toThrow("`start` and `end` must be provided together."); }); + it("rejects combining statsPeriod with an absolute monitor time range", async () => { + await expect( + getMonitorDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + statsPeriod: "24h", + start: "2025-04-14T02:00:00.000Z", + end: "2025-04-14T03:00:00.000Z", + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, + }, + context, + ), + ).rejects.toThrow( + "`statsPeriod` cannot be combined with `start` and `end`.", + ); + }); + it("defaults blank statsPeriod to a 24h monitor window", async () => { let checkInsRequestUrl: string | null = null; let statsRequestUrl: string | null = null; @@ -374,6 +397,14 @@ describe("get_monitor_details", () => { "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/checkins/", () => HttpResponse.json([]), ), + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/stats/", + () => + HttpResponse.json( + { detail: "stats should not be requested" }, + { status: 500 }, + ), + ), ); const result = await getMonitorDetails.handler( @@ -396,6 +427,7 @@ describe("get_monitor_details", () => { expect(result).toContain( "[Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/)", ); + expect(result).not.toContain("## Stats"); }); describe("tool definition", () => { diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index 305ef542..646bb65b 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -201,6 +201,11 @@ export default defineTool({ } const hasAbsoluteTimeRange = start !== undefined || end !== undefined; const requestedStatsPeriod = params.statsPeriod?.trim() || undefined; + if (hasAbsoluteTimeRange && requestedStatsPeriod) { + throw new UserInputError( + "`statsPeriod` cannot be combined with `start` and `end`.", + ); + } const statsPeriod = hasAbsoluteTimeRange ? undefined : (requestedStatsPeriod ?? "24h"); From 2c15f60f231e5bb2e0711989362776c8d7e75873 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 14:46:41 -0700 Subject: [PATCH 5/6] fix(monitors): Validate check-in stats periods Co-Authored-By: Codex --- .../mcp-core/src/api-client/client.test.ts | 28 +++++++++++++++++++ packages/mcp-core/src/api-client/client.ts | 28 +++++++++++++------ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 226b29ac..e4a916f8 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -379,6 +379,34 @@ describe("monitor time parameters", () => { expect(statsUrl.searchParams.get("since")).not.toBeNull(); expect(statsUrl.searchParams.get("until")).not.toBeNull(); }); + + it("rejects invalid monitor check-in statsPeriod values before sending a request", async () => { + let requestReceived = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/my-org/monitors/nightly-import/checkins/", + () => { + requestReceived = true; + return HttpResponse.json([]); + }, + ), + ); + + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + await expect( + apiService.listMonitorCheckIns({ + organizationSlug: "my-org", + monitorSlug: "nightly-import", + statsPeriod: "bogus", + limit: 10, + }), + ).rejects.toThrow("statsPeriod must use a supported relative time format"); + expect(requestReceived).toBe(false); + }); }); describe("network error handling", () => { diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 410050da..71f97797 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -135,6 +135,22 @@ function normalizeStatsPeriod(statsPeriod?: string): string | undefined { return normalized || undefined; } +function parseStatsPeriod(statsPeriod: string): { + amount: number; + unit: string; +} { + const match = /^(\d+)([smhdw])$/.exec(statsPeriod); + if (!match) { + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + return { + amount: Number(match[1]), + unit: match[2], + }; +} + function getNextCursor(linkHeader: string | null): string | null { if (!linkHeader) { return null; @@ -451,14 +467,7 @@ export class SentryApiService { until = Math.floor(endMs / 1000); } else { const period = statsPeriod ?? "24h"; - const match = /^(\d+)([smhdw])$/.exec(period); - if (!match) { - throw new ApiValidationError( - "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", - ); - } - const amount = Number(match[1]); - const unit = match[2]; + const { amount, unit } = parseStatsPeriod(period); let unitSeconds: number; switch (unit) { case "s": @@ -1960,6 +1969,9 @@ export class SentryApiService { } const effectiveStatsPeriod = start || end ? undefined : (normalizeStatsPeriod(statsPeriod) ?? "24h"); + if (effectiveStatsPeriod) { + parseStatsPeriod(effectiveStatsPeriod); + } this.applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); const encodedMonitor = encodeURIComponent(monitorSlug); From c09eb4f6be12902fb156e2776906e5921af99329 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 14:51:49 -0700 Subject: [PATCH 6/6] fix(monitors): Reject conflicting time ranges Co-Authored-By: Codex --- .../mcp-core/src/api-client/client.test.ts | 40 +++++++++++++++++++ packages/mcp-core/src/api-client/client.ts | 6 ++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index e4a916f8..c2ad4c6a 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -407,6 +407,46 @@ describe("monitor time parameters", () => { ).rejects.toThrow("statsPeriod must use a supported relative time format"); expect(requestReceived).toBe(false); }); + + it("rejects conflicting monitor statsPeriod and absolute time ranges before sending requests", async () => { + let requestReceived = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/my-org/monitors/nightly-import/checkins/", + () => { + requestReceived = true; + return HttpResponse.json([]); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/my-org/monitors/nightly-import/stats/", + () => { + requestReceived = true; + return HttpResponse.json([]); + }, + ), + ); + + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + const params = { + organizationSlug: "my-org", + monitorSlug: "nightly-import", + statsPeriod: "24h", + start: "2024-01-01T00:00:00Z", + end: "2024-01-02T00:00:00Z", + }; + + await expect(apiService.listMonitorCheckIns(params)).rejects.toThrow( + "Cannot use both statsPeriod and start/end parameters", + ); + await expect(apiService.getMonitorStats(params)).rejects.toThrow( + "Cannot use both statsPeriod and start/end parameters", + ); + expect(requestReceived).toBe(false); + }); }); describe("network error handling", () => { diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 71f97797..2d997943 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1967,8 +1967,9 @@ export class SentryApiService { if (limit !== undefined) { searchQuery.set("per_page", String(limit)); } + const normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); const effectiveStatsPeriod = - start || end ? undefined : (normalizeStatsPeriod(statsPeriod) ?? "24h"); + start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); if (effectiveStatsPeriod) { parseStatsPeriod(effectiveStatsPeriod); } @@ -2012,8 +2013,9 @@ export class SentryApiService { if (environment) { searchQuery.append("environment", environment); } + const normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); const effectiveStatsPeriod = - start || end ? undefined : (normalizeStatsPeriod(statsPeriod) ?? "24h"); + start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); this.applyStatsMixinTimeParams( searchQuery, effectiveStatsPeriod,