diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 956f0f4c..c2ad4c6a 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,126 @@ 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(); + }); + + 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); + }); + + 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", () => { 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..2d997943 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,27 @@ 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 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; @@ -405,6 +434,73 @@ 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 { amount, unit } = parseStatsPeriod(period); + 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 +859,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 +1862,180 @@ 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 normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); + const effectiveStatsPeriod = + start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); + if (effectiveStatsPeriod) { + parseStatsPeriod(effectiveStatsPeriod); + } + 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 normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); + const effectiveStatsPeriod = + start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "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/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 65b727bb..b570e2c1 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": ["project: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"] }, { diff --git a/packages/mcp-core/src/skills.test.ts b/packages/mcp-core/src/skills.test.ts index 8fa18517..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", () => { @@ -174,5 +186,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 823b32a3..43add1d0 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": ["project: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 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, 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/find-monitors.test.ts b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts new file mode 100644 index 00000000..ee550c72 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-monitors.test.ts @@ -0,0 +1,232 @@ +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("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( + "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..35045bf6 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -0,0 +1,196 @@ +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"; +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 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 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, + 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..92b8a450 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -0,0 +1,438 @@ +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("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("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; + 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([]), + ), + 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( + { + 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/)", + ); + expect(result).not.toContain("## Stats"); + }); + + 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 new file mode 100644 index 00000000..646bb65b --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -0,0 +1,322 @@ +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: ["project: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; + 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; + if (hasAbsoluteTimeRange && requestedStatsPeriod) { + throw new UserInputError( + "`statsPeriod` cannot be combined with `start` and `end`.", + ); + } + 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..83ef29c2 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,25 @@ function parseSpanResourceId(resourceId: string): { }; } +function assertCatalogToolAvailable( + context: ServerContext, + toolName: string, + resourceLabel: string, +) { + 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, @@ -439,33 +471,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, @@ -506,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: { @@ -521,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, 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.", @@ -536,9 +554,7 @@ export default defineTool({ `- With ?selectedSnapshot=: returns the image preview and metadata. ${fullResolutionInstruction}.`, "", "Resource IDs:", - "- span: :", - "- snapshot: ", - "- snapshotImage: :", + ...resourceIds, "", "", "get_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')", @@ -569,12 +585,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 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, 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(), @@ -609,7 +626,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 +731,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/",