diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7860157be..05d4aabb9 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2615,6 +2615,77 @@ export class SentryApiService { ); } + /** + * Create a cron monitor through the detectors API, as the Sentry UI does. + * Unlike the legacy `POST /organizations/{org}/monitors/`, this path records + * the acting user as the detector's creator and can connect Alerts. + * + * POST /organizations/{org}/projects/{project}/detectors/ + * Source: src/sentry/monitors/validators.py (MonitorIncidentDetectorValidator, + * MonitorDataSourceValidator, ConfigValidator) + * + * Returns the created Monitor, which Sentry serializes as the detector's + * `dataSources[0].queryObj`. + */ + async createMonitor( + { + organizationSlug, + projectSlug, + name, + slug, + status, + owner, + workflowIds, + config, + }: { + organizationSlug: string; + projectSlug: string; + name: string; + slug?: string; + status?: "active" | "disabled"; + owner?: string; + workflowIds?: string[]; + config: { + schedule_type: "crontab" | "interval"; + schedule: string | [number, string]; + checkin_margin?: number; + max_runtime?: number; + timezone?: string; + failure_issue_threshold?: number; + recovery_threshold?: number; + }; + }, + opts?: RequestOptions, + ): Promise<{ detector: Detector; monitor: Monitor }> { + const response = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/projects/${projectSlug}/detectors/`, + { + method: "POST", + body: JSON.stringify({ + type: "monitor_check_in_failure", + name, + ...(owner !== undefined && { owner }), + ...(workflowIds !== undefined && { workflowIds }), + dataSources: [ + { + name, + config, + ...(slug !== undefined && { slug }), + ...(status !== undefined && { status }), + ...(owner !== undefined && { owner }), + }, + ], + }), + }, + opts, + ); + const detector = DetectorSchema.parse(response); + return { + detector, + monitor: MonitorSchema.parse(detector.dataSources?.[0]?.queryObj), + }; + } + /** * Schedule deletion of a cron monitor environment. Returns 202 with no body. * Source: src/sentry/monitors/endpoints/project_monitor_environment_details.py diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 68ca9cc04..473a7bb9f 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -538,10 +538,10 @@ { "id": "project-management", "name": "Manage Projects & Teams", - "description": "Create and modify projects, teams, DSNs, cron monitor environments, uptime monitors, metric monitors, and alert rules", + "description": "Create and modify projects, teams, DSNs, cron monitors and their environments, uptime monitors, metric monitors, and alert rules", "defaultEnabled": false, "order": 5, - "toolCount": 24, + "toolCount": 25, "tools": [ { "name": "add_team_to_project", @@ -563,6 +563,11 @@ "description": "Create a Sentry Metric Monitor that detects when a metric, such as an error count or latency, crosses a threshold. Supports Static, Percent or Dynamic detection.\nProvide the complete query, detection config and conditions. Time windows and comparison deltas use seconds. Percent comparisons are absolute: 110 means 10% higher.\nTo copy a monitor, use the query, config and conditionGroup from get_metric_monitor_details. Component IDs are discarded; new condition groups use logicType='any'.\nNew monitors start ACTIVE and immediately evaluate data. Initially disabled creation is not supported. Use update_metric_monitor to disable an existing monitor.\nworkflowIds optionally connect existing notification Alerts, including shared Alerts, without modifying their configuration. Create an Alert separately with create_alert_rule if needed.\nDataset access, quota, aggregate, window and historical-data requirements are enforced by Sentry. Dynamic detection requires sufficient history and Seer availability.\nCreation can fail after persisting a monitor, especially during Dynamic setup. After an error or timeout, use find_metric_monitors with the name and project before retrying; do not assume rollback or blindly repeat creation.\ncreate_metric_monitor(organizationSlug='my-org', projectSlug='backend', name='High error count', query={dataset:'events', query:'level:error', aggregate:'count()', eventTypes:['error'], timeWindowSeconds:300}, config={detectionType:'static'}, conditionGroup={conditions:[{type:'gt', comparison:100, conditionResult:75}, {type:'lte', comparison:50, conditionResult:0}]})", "requiredScopes": ["org:read", "project:read", "alerts:write"] }, + { + "name": "create_monitor", + "description": "Create a Sentry cron monitor.\n\nUse this tool when you need to:\n- Monitor a scheduled job (cron job, scheduled task) for missed, failed, or slow runs\n- Set up a cron monitor before instrumenting check-ins\n\nSet `schedule` to exactly one crontab or interval schedule. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\nworkflowIds optionally connect existing notification Alerts without modifying them. Use find_alert_rules(kind='issue') to find workflow IDs.\nIf the returned status is `disabled` but you did not request that, the organization has no cron monitor seat available. Tell the user; the monitor ignores check-ins until a seat frees up and it is enabled.\nAfter an error or timeout, use find_monitors with the name and project before retrying; creation is not idempotent.\n\n\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Nightly Import', schedule={ type: 'crontab', crontab: '0 3 * * *' }, timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', schedule={ type: 'interval', value: 15, unit: 'minute' })\n", + "requiredScopes": ["org:read", "project:read", "alerts:write"] + }, { "name": "create_project", "description": "Create a new project in Sentry (provisions DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN when key setup succeeds.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index dd89d52b3..3cbf29ade 100644 --- a/packages/mcp-core/src/skills.ts +++ b/packages/mcp-core/src/skills.ts @@ -61,7 +61,7 @@ export const SKILLS: Record = { id: "project-management", name: "Manage Projects & Teams", description: - "Create and modify projects, teams, DSNs, cron monitor environments, uptime monitors, metric monitors, and alert rules", + "Create and modify projects, teams, DSNs, cron monitors and their environments, uptime monitors, metric monitors, and alert rules", defaultEnabled: false, order: 5, }, diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 13ed10118..a8fbf83e3 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -943,6 +943,226 @@ "skills": ["project-management"], "surface": "direct" }, + { + "name": "create_monitor", + "description": "Create a Sentry cron monitor.\n\nUse this tool when you need to:\n- Monitor a scheduled job (cron job, scheduled task) for missed, failed, or slow runs\n- Set up a cron monitor before instrumenting check-ins\n\nSet `schedule` to exactly one crontab or interval schedule. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\nworkflowIds optionally connect existing notification Alerts without modifying them. Use find_alert_rules(kind='issue') to find workflow IDs.\nIf the returned status is `disabled` but you did not request that, the organization has no cron monitor seat available. Tell the user; the monitor ignores check-ins until a seat frees up and it is enabled.\nAfter an error or timeout, use find_monitors with the name and project before retrying; creation is not idempotent.\n\n\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Nightly Import', schedule={ type: 'crontab', crontab: '0 3 * * *' }, timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', schedule={ type: 'interval', value: 15, unit: 'minute' })\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": { + "default": null, + "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" + } + ] + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Monitor display name." + }, + "slug": { + "type": "string", + "minLength": 1, + "description": "Optional monitor slug used by check-ins. Derived from the name when omitted." + }, + "schedule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "crontab" + }, + "crontab": { + "type": "string", + "minLength": 1, + "description": "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported." + } + }, + "required": ["type", "crontab"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "interval" + }, + "value": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "unit": { + "type": "string", + "enum": ["minute", "hour", "day", "week", "month", "year"] + } + }, + "required": ["type", "value", "unit"], + "additionalProperties": false + } + ], + "description": "Monitor schedule: exactly one of `{ type: 'crontab', crontab: '0 3 * * *' }` or `{ type: 'interval', value: 15, unit: 'minute' }`." + }, + "timezone": { + "type": "string", + "minLength": 1, + "description": "tz database timezone for the schedule, e.g. `UTC`." + }, + "checkInMargin": { + "type": "integer", + "minimum": 0, + "maximum": 40320, + "description": "Minutes after the expected time before a check-in counts as missed. Sentry treats 0 as 1." + }, + "maxRuntime": { + "type": "integer", + "minimum": 1, + "maximum": 10080, + "description": "Minutes a check-in may stay in progress before it fails." + }, + "failureIssueThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "description": "Consecutive failed or missed check-ins before an issue opens." + }, + "recoveryThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "description": "Consecutive successful check-ins before the issue resolves." + }, + "status": { + "type": "string", + "enum": ["active", "disabled"], + "description": "Monitor status. Disabled monitors ignore check-ins." + }, + "owner": { + "type": "string", + "pattern": "^(user|team):\\d+$", + "description": "Owner actor in `user:ID` or `team:ID` format." + }, + "workflowIds": { + "maxItems": 50, + "type": "array", + "items": { + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Existing notification Alert IDs to connect." + } + }, + "required": ["organizationSlug", "projectSlug", "name", "schedule"] + }, + "outputSchema": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "id": { + "description": "Cron monitor ID.", + "type": ["string", "null"] + }, + "slug": { + "type": "string", + "description": "Monitor slug that check-ins must use." + }, + "name": { + "type": "string" + }, + "projectSlug": { + "type": ["string", "null"] + }, + "status": { + "description": "`active` or `disabled`; unrequested `disabled` means no seat was available.", + "type": ["string", "null"] + }, + "scheduleType": { + "description": "`crontab` or `interval`.", + "type": ["string", "null"] + }, + "schedule": { + "description": "Crontab expression, or interval as ` `.", + "type": ["string", "null"] + }, + "timezone": { + "description": "tz database timezone.", + "type": ["string", "null"] + }, + "checkInMargin": { + "description": "Minutes before a late check-in counts as missed.", + "type": ["number", "null"] + }, + "maxRuntime": { + "description": "Minutes a check-in may stay in progress.", + "type": ["number", "null"] + }, + "failureIssueThreshold": { + "description": "Consecutive failures before an issue opens.", + "type": ["number", "null"] + }, + "recoveryThreshold": { + "description": "Consecutive successes before the issue resolves.", + "type": ["number", "null"] + }, + "workflowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Connected notification Alert IDs." + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Monitor page in Sentry." + } + }, + "required": [ + "id", + "slug", + "name", + "projectSlug", + "status", + "scheduleType", + "schedule", + "timezone", + "checkInMargin", + "maxRuntime", + "failureIssueThreshold", + "recoveryThreshold", + "workflowIds", + "webUrl" + ], + "additionalProperties": false + } + }, + "required": ["monitor"], + "additionalProperties": false + }, + "requiredScopes": ["org:read", "project:read", "alerts:write"], + "skills": ["project-management"], + "surface": "direct" + }, { "name": "create_project", "description": "Create a new project in Sentry (provisions DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN when key setup succeeds.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts new file mode 100644 index 000000000..a2312808f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts @@ -0,0 +1,417 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { createTestContext } from "../../test-utils/context.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import createMonitor, { createMonitorOutputSchema } from "./create-monitor.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; +const endpoint = + "https://sentry.io/api/0/organizations/sentry-mcp-evals/projects/cloudflare-mcp/detectors/"; +const baseParams = { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + name: "Nightly Import", +}; + +// Sentry answers with the created detector and serializes the Monitor as +// `dataSources[0].queryObj` (MonitorIncidentDetectorValidator). +function monitorResponse( + config: Record, + { + status = "active", + workflowIds = [], + }: { status?: string; workflowIds?: string[] } = {}, +) { + return { + id: "8812", + projectId: "4509062593708032", + name: "Nightly Import", + type: "monitor_check_in_failure", + enabled: status === "active", + config: {}, + conditionGroup: null, + workflowIds, + owner: null, + createdBy: "1", + dateCreated: "2026-11-03T12:00:00Z", + dateUpdated: "2026-11-03T12:00:00Z", + dataSources: [ + { + id: "991", + organizationId: "1", + type: "cron_monitor", + sourceId: "77", + queryObj: { + id: "c5f2a1b0-0000-4000-8000-000000000001", + slug: "nightly-import", + name: "Nightly Import", + status, + isMuted: false, + project: { + id: "4509062593708032", + slug: "cloudflare-mcp", + name: "cloudflare-mcp", + }, + owner: null, + config, + environments: [], + }, + }, + ], + }; +} + +describe("create_monitor", () => { + it("creates a crontab cron monitor with Sentry's config fields", async () => { + let requestBody: Record | null = null; + mswServer.use( + http.post(endpoint, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json( + monitorResponse({ + schedule_type: "crontab", + schedule: "0 3 * * *", + timezone: "America/Los_Angeles", + checkin_margin: 5, + max_runtime: 30, + failure_issue_threshold: 2, + recovery_threshold: 1, + }), + { status: 201 }, + ); + }), + ); + + const result = await createMonitor.handler( + { + ...baseParams, + schedule: { type: "crontab", crontab: "0 3 * * *" }, + timezone: "America/Los_Angeles", + checkInMargin: 5, + maxRuntime: 30, + failureIssueThreshold: 2, + recoveryThreshold: 1, + }, + context, + ); + + expect(requestBody).toEqual({ + type: "monitor_check_in_failure", + name: "Nightly Import", + dataSources: [ + { + name: "Nightly Import", + config: { + schedule_type: "crontab", + schedule: "0 3 * * *", + timezone: "America/Los_Angeles", + checkin_margin: 5, + max_runtime: 30, + failure_issue_threshold: 2, + recovery_threshold: 1, + }, + }, + ], + }); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect(createMonitorOutputSchema.parse(structuredContent)).toEqual( + structuredContent, + ); + expect(structuredContent).toMatchInlineSnapshot(` + { + "monitor": { + "checkInMargin": 5, + "failureIssueThreshold": 2, + "id": "c5f2a1b0-0000-4000-8000-000000000001", + "maxRuntime": 30, + "name": "Nightly Import", + "projectSlug": "cloudflare-mcp", + "recoveryThreshold": 1, + "schedule": "0 3 * * *", + "scheduleType": "crontab", + "slug": "nightly-import", + "status": "active", + "timezone": "America/Los_Angeles", + "webUrl": "https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/", + "workflowIds": [], + }, + } + `); + }); + + it("sends interval schedules as a [value, unit] tuple", async () => { + let requestBody: Record | null = null; + mswServer.use( + http.post(endpoint, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json( + monitorResponse({ + schedule_type: "interval", + schedule: [15, "minute"], + }), + { status: 201 }, + ); + }), + ); + + const result = await createMonitor.handler( + { + ...baseParams, + schedule: { type: "interval", value: 15, unit: "minute" }, + }, + context, + ); + + expect(requestBody).toMatchObject({ + dataSources: [ + { config: { schedule_type: "interval", schedule: [15, "minute"] } }, + ], + }); + expect(getStructuredContent(result)).toMatchObject({ + monitor: { scheduleType: "interval", schedule: "15 minute" }, + }); + }); + + const scheduleSchema = createMonitor.inputSchema.schedule; + + it.each([ + ["crontab", { type: "crontab", crontab: "@daily" }], + ["interval", { type: "interval", value: 15, unit: "minute" }], + ])("accepts a %s schedule in the input schema", (_label, schedule) => { + expect(scheduleSchema.safeParse(schedule).success).toBe(true); + }); + + it.each([ + ["neither", undefined], + ["an empty", {}], + ["both", { type: "crontab", crontab: "@daily", value: 1, unit: "day" }], + [ + "interval fields under crontab type", + { type: "crontab", value: 1, unit: "day" }, + ], + ["crontab under interval type", { type: "interval", crontab: "@daily" }], + [ + "both under interval type", + { type: "interval", value: 15, unit: "minute", crontab: "@daily" }, + ], + ])("rejects %s schedule in the input schema", (_label, schedule) => { + expect(scheduleSchema.safeParse(schedule).success).toBe(false); + }); + + it("rejects a missing schedule when parsing the full input", () => { + expect( + z.object(createMonitor.inputSchema).safeParse(baseParams).success, + ).toBe(false); + }); + + it("accepts checkInMargin=0 and forwards it for Sentry to coerce", async () => { + expect(createMonitor.inputSchema.checkInMargin.safeParse(0).success).toBe( + true, + ); + expect(createMonitor.inputSchema.checkInMargin.safeParse(-1).success).toBe( + false, + ); + let requestBody: Record | null = null; + mswServer.use( + http.post(endpoint, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json( + monitorResponse({ + schedule_type: "crontab", + schedule: "@daily", + checkin_margin: 1, + }), + { status: 201 }, + ); + }), + ); + + await createMonitor.handler( + { + ...baseParams, + schedule: { type: "crontab", crontab: "@daily" }, + checkInMargin: 0, + }, + context, + ); + + expect(requestBody).toMatchObject({ + dataSources: [{ config: { checkin_margin: 0 } }], + }); + }); + + it.each(["alice", "team:backend", "user:", "user:1:2"])( + "rejects malformed owner %s in the input schema", + (owner) => { + expect(createMonitor.inputSchema.owner.safeParse(owner).success).toBe( + false, + ); + }, + ); + + it.each([ + ["checkInMargin", 40_321], + ["maxRuntime", 10_081], + ["failureIssueThreshold", 721], + ["recoveryThreshold", 0], + ] as const)("rejects out-of-range %s=%d", (field, value) => { + expect(createMonitor.inputSchema[field].safeParse(value).success).toBe( + false, + ); + }); + + it("forwards slug, status, owner, and workflowIds", async () => { + let requestBody: Record | null = null; + mswServer.use( + http.post(endpoint, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json( + monitorResponse( + { schedule_type: "crontab", schedule: "@daily" }, + { status: "disabled", workflowIds: ["12"] }, + ), + { status: 201 }, + ); + }), + ); + + const result = await createMonitor.handler( + { + ...baseParams, + schedule: { type: "crontab", crontab: "@daily" }, + slug: "nightly-import", + status: "disabled", + owner: "team:456", + workflowIds: ["12"], + }, + context, + ); + + // The detector and its Monitor both carry the owner, matching what the + // legacy monitor endpoint copied onto the detector. + expect(requestBody).toEqual({ + type: "monitor_check_in_failure", + name: "Nightly Import", + owner: "team:456", + workflowIds: ["12"], + dataSources: [ + { + name: "Nightly Import", + config: { schedule_type: "crontab", schedule: "@daily" }, + slug: "nightly-import", + status: "disabled", + owner: "team:456", + }, + ], + }); + expect(getStructuredContent(result)).toMatchObject({ + monitor: { status: "disabled", workflowIds: ["12"] }, + }); + }); + + it("reports a monitor Sentry disabled for lack of a seat", async () => { + mswServer.use( + http.post(endpoint, () => + HttpResponse.json( + monitorResponse( + { schedule_type: "crontab", schedule: "@daily" }, + { status: "disabled" }, + ), + { status: 201 }, + ), + ), + ); + + const result = await createMonitor.handler( + { ...baseParams, schedule: { type: "crontab", crontab: "@daily" } }, + context, + ); + + expect(getStructuredContent(result)).toMatchObject({ + monitor: { status: "disabled" }, + }); + }); + + it.each([["abc"], ["12a"]])( + "rejects non-numeric workflow ID %s in the input schema", + (id) => { + expect( + createMonitor.inputSchema.workflowIds.safeParse([id]).success, + ).toBe(false); + }, + ); + + it("caps the number of workflow IDs in the input schema", () => { + const ids = (count: number) => + Array.from({ length: count }, (_, i) => String(i + 1)); + expect( + createMonitor.inputSchema.workflowIds.safeParse(ids(50)).success, + ).toBe(true); + expect( + createMonitor.inputSchema.workflowIds.safeParse(ids(51)).success, + ).toBe(false); + }); + + it("surfaces Sentry validation errors", async () => { + mswServer.use( + http.post(endpoint, () => + HttpResponse.json( + { + dataSources: { + config: { + schedule: ["Only 5 field crontab syntax is supported"], + }, + }, + }, + { status: 400 }, + ), + ), + ); + + await expect( + createMonitor.handler( + { + ...baseParams, + schedule: { type: "crontab", crontab: "0 0 3 * * *" }, + }, + context, + ), + ).rejects.toThrow("Only 5 field crontab syntax is supported"); + }); + + it("rejects a conflicting project before creating a monitor", async () => { + let writes = 0; + mswServer.use( + http.post(endpoint, () => { + writes += 1; + return HttpResponse.json({}, { status: 201 }); + }), + ); + const parsed = z.object(createMonitor.inputSchema).parse({ + ...baseParams, + schedule: { type: "crontab", crontab: "@daily" }, + }); + + await expect( + createMonitor.handler( + parsed, + createTestContext({ constraints: { projectSlug: "other-project" } }), + ), + ).rejects.toThrow("outside the active project constraint"); + expect(writes).toBe(0); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.ts b/packages/mcp-core/src/tools/catalog/create-monitor.ts new file mode 100644 index 000000000..0da49b09d --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -0,0 +1,285 @@ +import { z } from "zod"; +import type { Monitor } from "../../api-client/types"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; +import { assertProjectRefWithinConstraint } from "./support/project-constraints"; + +// Limits mirror src/sentry/monitors/constants.py. +const MAX_CHECKIN_MARGIN_MINUTES = 40_320; +const MAX_RUNTIME_MINUTES = 10_080; +const MAX_THRESHOLD = 720; +// Bounds untrusted input; far above any realistic number of Alerts per monitor. +const MAX_WORKFLOW_IDS = 50; + +const INTERVAL_UNITS = [ + "minute", + "hour", + "day", + "week", + "month", + "year", +] as const; + +export const createMonitorOutputSchema = z.object({ + monitor: z.object({ + id: z.string().nullable().describe("Cron monitor ID."), + slug: z.string().describe("Monitor slug that check-ins must use."), + name: z.string(), + projectSlug: z.string().nullable(), + status: z + .string() + .nullable() + .describe( + "`active` or `disabled`; unrequested `disabled` means no seat was available.", + ), + scheduleType: z.string().nullable().describe("`crontab` or `interval`."), + schedule: z + .string() + .nullable() + .describe("Crontab expression, or interval as ` `."), + timezone: z.string().nullable().describe("tz database timezone."), + checkInMargin: z + .number() + .nullable() + .describe("Minutes before a late check-in counts as missed."), + maxRuntime: z + .number() + .nullable() + .describe("Minutes a check-in may stay in progress."), + failureIssueThreshold: z + .number() + .nullable() + .describe("Consecutive failures before an issue opens."), + recoveryThreshold: z + .number() + .nullable() + .describe("Consecutive successes before the issue resolves."), + workflowIds: z + .array(z.string()) + .describe("Connected notification Alert IDs."), + webUrl: z.string().url().describe("Monitor page in Sentry."), + }), +}); + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" ? value : null; +} + +function formatSchedule(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) && value.length === 2) { + return `${value[0]} ${value[1]}`; + } + return null; +} + +function toMonitorSummary( + monitor: Monitor, + workflowIds: string[], + webUrl: string, +) { + const config = monitor.config ?? {}; + return { + id: monitor.id == null ? null : String(monitor.id), + slug: monitor.slug, + name: monitor.name ?? monitor.slug, + projectSlug: monitor.project?.slug ?? null, + status: monitor.status ?? null, + scheduleType: + typeof config.schedule_type === "string" ? config.schedule_type : null, + schedule: formatSchedule(config.schedule), + timezone: typeof config.timezone === "string" ? config.timezone : null, + checkInMargin: numberOrNull(config.checkin_margin), + maxRuntime: numberOrNull(config.max_runtime), + failureIssueThreshold: numberOrNull(config.failure_issue_threshold), + recoveryThreshold: numberOrNull(config.recovery_threshold), + workflowIds, + webUrl, + }; +} + +export default defineTool({ + name: "create_monitor", + skills: ["project-management"], + requiredScopes: ["org:read", "project:read", "alerts:write"], + description: [ + "Create a Sentry cron monitor.", + "", + "Use this tool when you need to:", + "- Monitor a scheduled job (cron job, scheduled task) for missed, failed, or slow runs", + "- Set up a cron monitor before instrumenting check-ins", + "", + "Set `schedule` to exactly one crontab or interval schedule. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.", + "workflowIds optionally connect existing notification Alerts without modifying them. Use find_alert_rules(kind='issue') to find workflow IDs.", + "If the returned status is `disabled` but you did not request that, the organization has no cron monitor seat available. Tell the user; the monitor ignores check-ins until a seat frees up and it is enabled.", + "After an error or timeout, use find_monitors with the name and project before retrying; creation is not idempotent.", + "", + "", + "create_monitor(organizationSlug='my-organization', projectSlug='backend', name='Nightly Import', schedule={ type: 'crontab', crontab: '0 3 * * *' }, timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)", + "create_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', schedule={ type: 'interval', value: 15, unit: 'minute' })", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + name: z.string().trim().min(1).max(128).describe("Monitor display name."), + slug: z + .string() + .trim() + .min(1) + .describe( + "Optional monitor slug used by check-ins. Derived from the name when omitted.", + ) + .optional(), + schedule: z + .discriminatedUnion("type", [ + z + .object({ + type: z.literal("crontab"), + crontab: z + .string() + .trim() + .min(1) + .describe( + "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported.", + ), + }) + .strict(), + z + .object({ + type: z.literal("interval"), + value: z.number().int().positive(), + unit: z.enum(INTERVAL_UNITS), + }) + .strict(), + ]) + .describe( + "Monitor schedule: exactly one of `{ type: 'crontab', crontab: '0 3 * * *' }` or `{ type: 'interval', value: 15, unit: 'minute' }`.", + ), + timezone: z + .string() + .trim() + .min(1) + .describe("tz database timezone for the schedule, e.g. `UTC`.") + .optional(), + checkInMargin: z + .number() + .int() + .min(0) + .max(MAX_CHECKIN_MARGIN_MINUTES) + .describe( + "Minutes after the expected time before a check-in counts as missed. Sentry treats 0 as 1.", + ) + .optional(), + maxRuntime: z + .number() + .int() + .min(1) + .max(MAX_RUNTIME_MINUTES) + .describe("Minutes a check-in may stay in progress before it fails.") + .optional(), + failureIssueThreshold: z + .number() + .int() + .min(1) + .max(MAX_THRESHOLD) + .describe("Consecutive failed or missed check-ins before an issue opens.") + .optional(), + recoveryThreshold: z + .number() + .int() + .min(1) + .max(MAX_THRESHOLD) + .describe("Consecutive successful check-ins before the issue resolves.") + .optional(), + status: z + .enum(["active", "disabled"]) + .describe("Monitor status. Disabled monitors ignore check-ins.") + .optional(), + owner: z + .string() + .trim() + .regex( + /^(user|team):\d+$/, + "owner must use `user:ID` or `team:ID` format (e.g. `user:123` or `team:456`).", + ) + .describe("Owner actor in `user:ID` or `team:ID` format.") + .optional(), + workflowIds: z + .array(z.string().regex(/^\d+$/)) + .max(MAX_WORKFLOW_IDS) + .describe("Existing notification Alert IDs to connect.") + .optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: createMonitorOutputSchema, + async handler(params, context: ServerContext) { + const { schedule } = params; + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + setTargetTagsAndAttributes(params); + + assertProjectRefWithinConstraint({ + resourceLabel: "Cron monitor", + scopedProjectSlug: context.constraints.projectSlug, + project: { slug: params.projectSlug }, + }); + + const { detector, monitor } = await apiService.createMonitor({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + slug: params.slug, + status: params.status, + owner: params.owner, + workflowIds: params.workflowIds, + config: { + ...(schedule.type === "crontab" + ? { schedule_type: "crontab" as const, schedule: schedule.crontab } + : { + schedule_type: "interval" as const, + schedule: [schedule.value, schedule.unit] as [number, string], + }), + ...(params.timezone !== undefined && { timezone: params.timezone }), + ...(params.checkInMargin !== undefined && { + checkin_margin: params.checkInMargin, + }), + ...(params.maxRuntime !== undefined && { + max_runtime: params.maxRuntime, + }), + ...(params.failureIssueThreshold !== undefined && { + failure_issue_threshold: params.failureIssueThreshold, + }), + ...(params.recoveryThreshold !== undefined && { + recovery_threshold: params.recoveryThreshold, + }), + }, + }); + + const projectSlug = monitor.project?.slug ?? params.projectSlug; + return structuredResult({ + monitor: toMonitorSummary( + monitor, + detector.workflowIds ?? [], + apiService.getMonitorUrl(organizationSlug, monitor.slug, projectSlug), + ), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index f1ab7ffa7..8d0b81765 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -9,6 +9,7 @@ import findDashboards from "./find-dashboards"; import getDashboardDetails from "./get-dashboard-details"; import findMonitors from "./find-monitors"; import getMonitorDetails from "./get-monitor-details"; +import createMonitor from "./create-monitor"; import deleteMonitorEnvironment from "./delete-monitor-environment"; import findUptimeMonitors from "./find-uptime-monitors"; import getUptimeMonitorDetails from "./get-uptime-monitor-details"; @@ -139,6 +140,7 @@ const catalogTools = withFreshReadInputs({ get_dashboard_details: getDashboardDetails, find_monitors: findMonitors, get_monitor_details: getMonitorDetails, + create_monitor: createMonitor, delete_monitor_environment: deleteMonitorEnvironment, find_uptime_monitors: findUptimeMonitors, get_uptime_monitor_details: getUptimeMonitorDetails, diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index 5d987ad5f..76ac3faa9 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -16,6 +16,7 @@ allowedTools: - create_alert_rule - create_dsn - create_metric_monitor + - create_monitor - create_project - create_team - create_uptime_monitor diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index a7400b85b..b663843b6 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -16,6 +16,7 @@ allowedTools: - create_alert_rule - create_dsn - create_metric_monitor + - create_monitor - create_project - create_team - create_uptime_monitor