From 9de419415231d0172a462b83e47f4389195037c6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:05:37 +0000 Subject: [PATCH 1/6] feat(mcp): Add create_monitor tool for cron monitors Agents could create uptime and metric monitors but not cron monitors, so they sent users to the UI. Add a catalog-only create_monitor tool gated by the project-management skill that calls POST /organizations/{org}/monitors/ with Sentry's ConfigValidator fields (schedule, timezone, check-in margin, max runtime, failure/recovery thresholds), and point find_monitors at it. Fixes #1845 Co-Authored-By: Grok Bot Originally authored by Alec Gutman (@Chipagosfinest) in #1846. --- packages/mcp-core/src/api-client/client.ts | 55 ++++ packages/mcp-core/src/skillDefinitions.json | 11 +- packages/mcp-core/src/skills.ts | 2 +- packages/mcp-core/src/toolDefinitions.json | 174 +++++++++++- .../src/tools/catalog/create-monitor.test.ts | 248 ++++++++++++++++++ .../src/tools/catalog/create-monitor.ts | 246 +++++++++++++++++ .../src/tools/catalog/find-monitors.ts | 2 + packages/mcp-core/src/tools/catalog/index.ts | 2 + .../agents/sentry-mcp.md | 1 + plugins/sentry-mcp/agents/sentry-mcp.md | 1 + 10 files changed, 737 insertions(+), 5 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/create-monitor.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/create-monitor.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7860157be..c683c4ebb 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2615,6 +2615,61 @@ export class SentryApiService { ); } + /** + * Create a cron monitor. + * + * POST /organizations/{org}/monitors/ + * Source: src/sentry/monitors/endpoints/organization_monitor_index.py + * Body fields: src/sentry/monitors/validators.py (MonitorValidator, ConfigValidator) + */ + async createMonitor( + { + organizationSlug, + projectSlug, + name, + slug, + status, + owner, + config, + }: { + organizationSlug: string; + projectSlug: string; + name: string; + slug?: string; + status?: "active" | "disabled"; + owner?: 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 { + const payload: Record = { + project: projectSlug, + name, + config, + }; + if (slug !== undefined) payload.slug = slug; + if (status !== undefined) payload.status = status; + if (owner !== undefined) payload.owner = owner; + + const body = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/monitors/`, + { + method: "POST", + body: JSON.stringify(payload), + }, + opts, + ); + return MonitorSchema.parse(body); + } + /** * 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..047e94788 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -29,7 +29,7 @@ }, { "name": "find_monitors", - "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", + "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\nTo create a cron monitor, use create_monitor.\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", "requiredScopes": ["org:read"] }, { @@ -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\nProvide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', 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..87f7c5eb2 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -943,6 +943,178 @@ "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\nProvide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', 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." + }, + "crontab": { + "type": "string", + "minLength": 1, + "description": "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported." + }, + "interval": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "unit": { + "type": "string", + "enum": ["minute", "hour", "day", "week", "month", "year"] + } + }, + "required": ["value", "unit"], + "description": "Interval schedule, e.g. `{ value: 15, unit: 'minute' }`." + }, + "timezone": { + "type": "string", + "minLength": 1, + "description": "tz database timezone for the schedule, e.g. `UTC`." + }, + "checkInMargin": { + "type": "integer", + "minimum": 1, + "maximum": 40320, + "description": "Minutes after the expected time before a check-in counts as missed." + }, + "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." + } + }, + "required": ["organizationSlug", "projectSlug", "name"] + }, + "outputSchema": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "id": { + "type": ["string", "null"] + }, + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectSlug": { + "type": ["string", "null"] + }, + "status": { + "type": ["string", "null"] + }, + "scheduleType": { + "type": ["string", "null"] + }, + "schedule": { + "type": ["string", "null"] + }, + "timezone": { + "type": ["string", "null"] + }, + "checkInMargin": { + "type": ["number", "null"] + }, + "maxRuntime": { + "type": ["number", "null"] + }, + "failureIssueThreshold": { + "type": ["number", "null"] + }, + "recoveryThreshold": { + "type": ["number", "null"] + }, + "webUrl": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "slug", + "name", + "projectSlug", + "status", + "scheduleType", + "schedule", + "timezone", + "checkInMargin", + "maxRuntime", + "failureIssueThreshold", + "recoveryThreshold", + "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", @@ -2311,7 +2483,7 @@ }, { "name": "find_monitors", - "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", + "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\nTo create a cron monitor, use create_monitor.\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", "inputSchema": { "type": "object", "properties": { 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..f10d0bf1f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts @@ -0,0 +1,248 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { UserInputError } from "../../errors"; +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/monitors/"; +const baseParams = { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + name: "Nightly Import", +}; + +function monitorResponse(config: Record) { + return { + id: "c5f2a1b0-0000-4000-8000-000000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "active", + 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, + crontab: "0 3 * * *", + timezone: "America/Los_Angeles", + checkInMargin: 5, + maxRuntime: 30, + failureIssueThreshold: 2, + recoveryThreshold: 1, + }, + context, + ); + + expect(requestBody).toEqual({ + project: "cloudflare-mcp", + 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/", + }, + } + `); + }); + + 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, interval: { value: 15, unit: "minute" } }, + context, + ); + + expect(requestBody).toMatchObject({ + config: { schedule_type: "interval", schedule: [15, "minute"] }, + }); + expect(getStructuredContent(result)).toMatchObject({ + monitor: { scheduleType: "interval", schedule: "15 minute" }, + }); + }); + + it.each([ + ["neither", {}], + [ + "both", + { crontab: "@daily", interval: { value: 1, unit: "day" as const } }, + ], + ])("rejects %s crontab and interval", async (_label, schedule) => { + await expect( + createMonitor.handler({ ...baseParams, ...schedule }, context), + ).rejects.toThrow(UserInputError); + }); + + 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, and owner", 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: 201 }, + ); + }), + ); + + await createMonitor.handler( + { + ...baseParams, + crontab: "@daily", + slug: "nightly-import", + status: "disabled", + owner: "team:456", + }, + context, + ); + + expect(requestBody).toMatchObject({ + slug: "nightly-import", + status: "disabled", + owner: "team:456", + }); + }); + + it("surfaces Sentry validation errors", async () => { + mswServer.use( + http.post(endpoint, () => + HttpResponse.json( + { + config: { schedule: ["Only 5 field crontab syntax is supported"] }, + }, + { status: 400 }, + ), + ), + ); + + await expect( + createMonitor.handler({ ...baseParams, 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, 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..446ed255b --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -0,0 +1,246 @@ +import { z } from "zod"; +import type { Monitor } from "../../api-client/types"; +import { UserInputError } from "../../errors"; +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; + +const INTERVAL_UNITS = [ + "minute", + "hour", + "day", + "week", + "month", + "year", +] as const; + +export const createMonitorOutputSchema = z.object({ + monitor: z.object({ + id: z.string().nullable(), + slug: z.string(), + name: z.string(), + projectSlug: z.string().nullable(), + status: z.string().nullable(), + scheduleType: z.string().nullable(), + schedule: z.string().nullable(), + timezone: z.string().nullable(), + checkInMargin: z.number().nullable(), + maxRuntime: z.number().nullable(), + failureIssueThreshold: z.number().nullable(), + recoveryThreshold: z.number().nullable(), + webUrl: z.string().url(), + }), +}); + +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, 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), + 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", + "", + "Provide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.", + "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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)", + "create_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', 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(), + crontab: z + .string() + .trim() + .min(1) + .describe( + "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported.", + ) + .optional(), + interval: z + .object({ + value: z.number().int().positive(), + unit: z.enum(INTERVAL_UNITS), + }) + .describe("Interval schedule, e.g. `{ value: 15, unit: 'minute' }`.") + .optional(), + timezone: z + .string() + .trim() + .min(1) + .describe("tz database timezone for the schedule, e.g. `UTC`.") + .optional(), + checkInMargin: z + .number() + .int() + .min(1) + .max(MAX_CHECKIN_MARGIN_MINUTES) + .describe( + "Minutes after the expected time before a check-in counts as missed.", + ) + .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(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: createMonitorOutputSchema, + async handler(params, context: ServerContext) { + const schedule = params.crontab ?? params.interval; + if ( + schedule === undefined || + (params.crontab !== undefined && params.interval !== undefined) + ) { + throw new UserInputError( + "Provide exactly one of `crontab` or `interval` for the monitor schedule.", + ); + } + + 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 monitor = await apiService.createMonitor({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + slug: params.slug, + status: params.status, + owner: params.owner, + config: { + ...(typeof schedule === "string" + ? { schedule_type: "crontab" as const, schedule } + : { + 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, + apiService.getMonitorUrl(organizationSlug, monitor.slug, projectSlug), + ), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.ts b/packages/mcp-core/src/tools/catalog/find-monitors.ts index 406326484..f38a19d84 100644 --- a/packages/mcp-core/src/tools/catalog/find-monitors.ts +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -149,6 +149,8 @@ export default defineTool({ "- Check monitor status, owner, project, schedule, or recent environment state", "- When `hasMore` is true, narrow the results with project, environment, owner, or query filters", "", + "To create a cron monitor, use create_monitor.", + "", "", "find_monitors(organizationSlug='my-organization')", "find_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')", 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 From b4dfb2b45e5081666e1d4f1036fd18f27fb220df Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:05:44 +0000 Subject: [PATCH 2/6] fix(mcp): Enforce create_monitor schedule shape in the input schema The exactly-one crontab/interval rule lived only in the handler, so the generated tool schema allowed both or neither. Replace the two optional fields with a required `schedule` discriminated union of strict crontab and interval variants, which generates a oneOf schema. Allow checkInMargin=0, which Sentry's MissedMarginField accepts and coerces to 1. Co-Authored-By: Grok Bot Originally authored by Alec Gutman (@Chipagosfinest) in #1846. --- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 64 ++++++++----- .../src/tools/catalog/create-monitor.test.ts | 93 ++++++++++++++++--- .../src/tools/catalog/create-monitor.ts | 64 ++++++------- 4 files changed, 153 insertions(+), 70 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 047e94788..6e38210c7 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -565,7 +565,7 @@ }, { "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\nProvide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', interval={ value: 15, unit: 'minute' })\n", + "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.\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"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 87f7c5eb2..314415c1b 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -945,7 +945,7 @@ }, { "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\nProvide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.\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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)\ncreate_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', interval={ value: 15, unit: 'minute' })\n", + "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.\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": { @@ -980,26 +980,46 @@ "minLength": 1, "description": "Optional monitor slug used by check-ins. Derived from the name when omitted." }, - "crontab": { - "type": "string", - "minLength": 1, - "description": "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported." - }, - "interval": { - "type": "object", - "properties": { - "value": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "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 }, - "unit": { - "type": "string", - "enum": ["minute", "hour", "day", "week", "month", "year"] + { + "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 } - }, - "required": ["value", "unit"], - "description": "Interval schedule, e.g. `{ value: 15, unit: 'minute' }`." + ], + "description": "Monitor schedule: exactly one of `{ type: 'crontab', crontab: '0 3 * * *' }` or `{ type: 'interval', value: 15, unit: 'minute' }`." }, "timezone": { "type": "string", @@ -1008,9 +1028,9 @@ }, "checkInMargin": { "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 40320, - "description": "Minutes after the expected time before a check-in counts as missed." + "description": "Minutes after the expected time before a check-in counts as missed. Sentry treats 0 as 1." }, "maxRuntime": { "type": "integer", @@ -1041,7 +1061,7 @@ "description": "Owner actor in `user:ID` or `team:ID` format." } }, - "required": ["organizationSlug", "projectSlug", "name"] + "required": ["organizationSlug", "projectSlug", "name", "schedule"] }, "outputSchema": { "type": "object", diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts index f10d0bf1f..18c561a9d 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts @@ -2,7 +2,6 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { UserInputError } from "../../errors"; import { createTestContext } from "../../test-utils/context.js"; import { assertStructuredOnlyResult, @@ -68,7 +67,7 @@ describe("create_monitor", () => { const result = await createMonitor.handler( { ...baseParams, - crontab: "0 3 * * *", + schedule: { type: "crontab", crontab: "0 3 * * *" }, timezone: "America/Los_Angeles", checkInMargin: 5, maxRuntime: 30, @@ -134,7 +133,10 @@ describe("create_monitor", () => { ); const result = await createMonitor.handler( - { ...baseParams, interval: { value: 15, unit: "minute" } }, + { + ...baseParams, + schedule: { type: "interval", value: 15, unit: "minute" }, + }, context, ); @@ -146,16 +148,70 @@ describe("create_monitor", () => { }); }); + const scheduleSchema = createMonitor.inputSchema.schedule; + it.each([ - ["neither", {}], + ["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" }], [ - "both", - { crontab: "@daily", interval: { value: 1, unit: "day" as const } }, + "interval fields under crontab type", + { type: "crontab", value: 1, unit: "day" }, ], - ])("rejects %s crontab and interval", async (_label, schedule) => { - await expect( - createMonitor.handler({ ...baseParams, ...schedule }, context), - ).rejects.toThrow(UserInputError); + ["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({ config: { checkin_margin: 0 } }); }); it.each(["alice", "team:backend", "user:", "user:1:2"])( @@ -193,7 +249,7 @@ describe("create_monitor", () => { await createMonitor.handler( { ...baseParams, - crontab: "@daily", + schedule: { type: "crontab", crontab: "@daily" }, slug: "nightly-import", status: "disabled", owner: "team:456", @@ -221,7 +277,13 @@ describe("create_monitor", () => { ); await expect( - createMonitor.handler({ ...baseParams, crontab: "0 0 3 * * *" }, context), + createMonitor.handler( + { + ...baseParams, + schedule: { type: "crontab", crontab: "0 0 3 * * *" }, + }, + context, + ), ).rejects.toThrow("Only 5 field crontab syntax is supported"); }); @@ -233,9 +295,10 @@ describe("create_monitor", () => { return HttpResponse.json({}, { status: 201 }); }), ); - const parsed = z - .object(createMonitor.inputSchema) - .parse({ ...baseParams, crontab: "@daily" }); + const parsed = z.object(createMonitor.inputSchema).parse({ + ...baseParams, + schedule: { type: "crontab", crontab: "@daily" }, + }); await expect( createMonitor.handler( diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.ts b/packages/mcp-core/src/tools/catalog/create-monitor.ts index 446ed255b..afb025235 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import type { Monitor } from "../../api-client/types"; -import { UserInputError } from "../../errors"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; import { structuredResult } from "../../internal/tool-helpers/results"; @@ -90,12 +89,12 @@ export default defineTool({ "- Monitor a scheduled job (cron job, scheduled task) for missed, failed, or slow runs", "- Set up a cron monitor before instrumenting check-ins", "", - "Provide exactly one of `crontab` or `interval`. Check-ins must still be sent by the job (SDK `monitor_config`/check-ins or `sentry monitor run`) using the returned slug.", + "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.", "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', crontab='0 3 * * *', timezone='America/Los_Angeles', checkInMargin=5, maxRuntime=30)", - "create_monitor(organizationSlug='my-organization', projectSlug='backend', name='Queue Drain', interval={ value: 15, unit: 'minute' })", + "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: { @@ -111,21 +110,31 @@ export default defineTool({ "Optional monitor slug used by check-ins. Derived from the name when omitted.", ) .optional(), - crontab: z - .string() - .trim() - .min(1) + 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( - "Five-field crontab schedule or `@` alias, e.g. `0 3 * * *` or `@daily`. Seconds fields are not supported.", - ) - .optional(), - interval: z - .object({ - value: z.number().int().positive(), - unit: z.enum(INTERVAL_UNITS), - }) - .describe("Interval schedule, e.g. `{ value: 15, unit: 'minute' }`.") - .optional(), + "Monitor schedule: exactly one of `{ type: 'crontab', crontab: '0 3 * * *' }` or `{ type: 'interval', value: 15, unit: 'minute' }`.", + ), timezone: z .string() .trim() @@ -135,10 +144,10 @@ export default defineTool({ checkInMargin: z .number() .int() - .min(1) + .min(0) .max(MAX_CHECKIN_MARGIN_MINUTES) .describe( - "Minutes after the expected time before a check-in counts as missed.", + "Minutes after the expected time before a check-in counts as missed. Sentry treats 0 as 1.", ) .optional(), maxRuntime: z @@ -183,16 +192,7 @@ export default defineTool({ }, outputSchema: createMonitorOutputSchema, async handler(params, context: ServerContext) { - const schedule = params.crontab ?? params.interval; - if ( - schedule === undefined || - (params.crontab !== undefined && params.interval !== undefined) - ) { - throw new UserInputError( - "Provide exactly one of `crontab` or `interval` for the monitor schedule.", - ); - } - + const { schedule } = params; const apiService = apiServiceFromContext(context, { regionUrl: params.regionUrl ?? undefined, }); @@ -213,8 +213,8 @@ export default defineTool({ status: params.status, owner: params.owner, config: { - ...(typeof schedule === "string" - ? { schedule_type: "crontab" as const, schedule } + ...(schedule.type === "crontab" + ? { schedule_type: "crontab" as const, schedule: schedule.crontab } : { schedule_type: "interval" as const, schedule: [schedule.value, schedule.unit] as [number, string], From 1ff91e93ed420bd7fd26ba194a05cd3c30b84651 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:05:50 +0000 Subject: [PATCH 3/6] fix(mcp): Create cron monitors through the detectors endpoint The legacy POST /organizations/{org}/monitors/ endpoint creates the cron detector without created_by_id, so monitors an agent creates have no creator. Post to the project detectors endpoint the Sentry UI uses (type monitor_check_in_failure), which records the acting user and can connect existing Alerts through workflowIds, as create_metric_monitor does. The created Monitor comes back as dataSources[0].queryObj, so the output shape is unchanged apart from the connected workflowIds. Sentry still returns success when no monitor seat is available but disables the monitor, so the description tells agents to report an unrequested disabled status. --- packages/mcp-core/src/api-client/client.ts | 52 ++++-- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 17 +- .../src/tools/catalog/create-monitor.test.ts | 159 ++++++++++++++---- .../src/tools/catalog/create-monitor.ts | 18 +- 5 files changed, 194 insertions(+), 54 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index c683c4ebb..05d4aabb9 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2616,11 +2616,16 @@ export class SentryApiService { } /** - * Create a cron monitor. + * 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}/monitors/ - * Source: src/sentry/monitors/endpoints/organization_monitor_index.py - * Body fields: src/sentry/monitors/validators.py (MonitorValidator, ConfigValidator) + * 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( { @@ -2630,6 +2635,7 @@ export class SentryApiService { slug, status, owner, + workflowIds, config, }: { organizationSlug: string; @@ -2638,6 +2644,7 @@ export class SentryApiService { slug?: string; status?: "active" | "disabled"; owner?: string; + workflowIds?: string[]; config: { schedule_type: "crontab" | "interval"; schedule: string | [number, string]; @@ -2649,25 +2656,34 @@ export class SentryApiService { }; }, opts?: RequestOptions, - ): Promise { - const payload: Record = { - project: projectSlug, - name, - config, - }; - if (slug !== undefined) payload.slug = slug; - if (status !== undefined) payload.status = status; - if (owner !== undefined) payload.owner = owner; - - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/monitors/`, + ): Promise<{ detector: Detector; monitor: Monitor }> { + const response = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/projects/${projectSlug}/detectors/`, { method: "POST", - body: JSON.stringify(payload), + 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, ); - return MonitorSchema.parse(body); + const detector = DetectorSchema.parse(response); + return { + detector, + monitor: MonitorSchema.parse(detector.dataSources?.[0]?.queryObj), + }; } /** diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 6e38210c7..09576ab27 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -565,7 +565,7 @@ }, { "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.\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", + "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"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 314415c1b..b7b129908 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -945,7 +945,7 @@ }, { "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.\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", + "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": { @@ -1059,6 +1059,14 @@ "type": "string", "pattern": "^(user|team):\\d+$", "description": "Owner actor in `user:ID` or `team:ID` format." + }, + "workflowIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Existing notification Alert IDs to connect." } }, "required": ["organizationSlug", "projectSlug", "name", "schedule"] @@ -1105,6 +1113,12 @@ "recoveryThreshold": { "type": ["number", "null"] }, + "workflowIds": { + "type": "array", + "items": { + "type": "string" + } + }, "webUrl": { "type": "string", "format": "uri" @@ -1123,6 +1137,7 @@ "maxRuntime", "failureIssueThreshold", "recoveryThreshold", + "workflowIds", "webUrl" ], "additionalProperties": false diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts index 18c561a9d..8a2b6e36e 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts @@ -17,7 +17,7 @@ const context = { userId: "1", }; const endpoint = - "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/"; + "https://sentry.io/api/0/organizations/sentry-mcp-evals/projects/cloudflare-mcp/detectors/"; const baseParams = { organizationSlug: "sentry-mcp-evals", regionUrl: null, @@ -25,21 +25,51 @@ const baseParams = { name: "Nightly Import", }; -function monitorResponse(config: Record) { +// 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: "c5f2a1b0-0000-4000-8000-000000000001", - slug: "nightly-import", + id: "8812", + projectId: "4509062593708032", name: "Nightly Import", - status: "active", - isMuted: false, - project: { - id: "4509062593708032", - slug: "cloudflare-mcp", - name: "cloudflare-mcp", - }, + type: "monitor_check_in_failure", + enabled: status === "active", + config: {}, + conditionGroup: null, + workflowIds, owner: null, - config, - environments: [], + 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: [], + }, + }, + ], }; } @@ -78,17 +108,22 @@ describe("create_monitor", () => { ); expect(requestBody).toEqual({ - project: "cloudflare-mcp", + type: "monitor_check_in_failure", 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, - }, + 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); @@ -112,6 +147,7 @@ describe("create_monitor", () => { "status": "active", "timezone": "America/Los_Angeles", "webUrl": "https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/", + "workflowIds": [], }, } `); @@ -141,7 +177,9 @@ describe("create_monitor", () => { ); expect(requestBody).toMatchObject({ - config: { schedule_type: "interval", schedule: [15, "minute"] }, + dataSources: [ + { config: { schedule_type: "interval", schedule: [15, "minute"] } }, + ], }); expect(getStructuredContent(result)).toMatchObject({ monitor: { scheduleType: "interval", schedule: "15 minute" }, @@ -211,7 +249,9 @@ describe("create_monitor", () => { context, ); - expect(requestBody).toMatchObject({ config: { checkin_margin: 0 } }); + expect(requestBody).toMatchObject({ + dataSources: [{ config: { checkin_margin: 0 } }], + }); }); it.each(["alice", "team:backend", "user:", "user:1:2"])( @@ -234,42 +274,97 @@ describe("create_monitor", () => { ); }); - it("forwards slug, status, and owner", async () => { + 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" }), + monitorResponse( + { schedule_type: "crontab", schedule: "@daily" }, + { status: "disabled", workflowIds: ["12"] }, + ), { status: 201 }, ); }), ); - await createMonitor.handler( + const result = await createMonitor.handler( { ...baseParams, schedule: { type: "crontab", crontab: "@daily" }, slug: "nightly-import", status: "disabled", owner: "team:456", + workflowIds: ["12"], }, context, ); - expect(requestBody).toMatchObject({ - slug: "nightly-import", - status: "disabled", + // 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("surfaces Sentry validation errors", async () => { mswServer.use( http.post(endpoint, () => HttpResponse.json( { - config: { schedule: ["Only 5 field crontab syntax is supported"] }, + dataSources: { + config: { + schedule: ["Only 5 field crontab syntax is supported"], + }, + }, }, { status: 400 }, ), diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.ts b/packages/mcp-core/src/tools/catalog/create-monitor.ts index afb025235..85f7738e4 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -40,6 +40,7 @@ export const createMonitorOutputSchema = z.object({ maxRuntime: z.number().nullable(), failureIssueThreshold: z.number().nullable(), recoveryThreshold: z.number().nullable(), + workflowIds: z.array(z.string()), webUrl: z.string().url(), }), }); @@ -58,7 +59,11 @@ function formatSchedule(value: unknown): string | null { return null; } -function toMonitorSummary(monitor: Monitor, webUrl: string) { +function toMonitorSummary( + monitor: Monitor, + workflowIds: string[], + webUrl: string, +) { const config = monitor.config ?? {}; return { id: monitor.id == null ? null : String(monitor.id), @@ -74,6 +79,7 @@ function toMonitorSummary(monitor: Monitor, webUrl: string) { maxRuntime: numberOrNull(config.max_runtime), failureIssueThreshold: numberOrNull(config.failure_issue_threshold), recoveryThreshold: numberOrNull(config.recovery_threshold), + workflowIds, webUrl, }; } @@ -90,6 +96,8 @@ export default defineTool({ "- 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.", "", "", @@ -184,6 +192,10 @@ export default defineTool({ ) .describe("Owner actor in `user:ID` or `team:ID` format.") .optional(), + workflowIds: z + .array(z.string().regex(/^\d+$/)) + .describe("Existing notification Alert IDs to connect.") + .optional(), }, annotations: { readOnlyHint: false, @@ -205,13 +217,14 @@ export default defineTool({ project: { slug: params.projectSlug }, }); - const monitor = await apiService.createMonitor({ + 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 } @@ -239,6 +252,7 @@ export default defineTool({ return structuredResult({ monitor: toMonitorSummary( monitor, + detector.workflowIds ?? [], apiService.getMonitorUrl(organizationSlug, monitor.slug, projectSlug), ), }); From 0bcc5373e49ed76cb60999ed03963ec2e8b7999e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:05:58 +0000 Subject: [PATCH 4/6] ref(mcp): Drop create_monitor pointer from find_monitors Agents already find create_monitor through tool search, and the pointer could steer them away from SDK monitor_config setup. --- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 2 +- packages/mcp-core/src/tools/catalog/find-monitors.ts | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 09576ab27..473a7bb9f 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -29,7 +29,7 @@ }, { "name": "find_monitors", - "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\nTo create a cron monitor, use create_monitor.\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", + "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", "requiredScopes": ["org:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index b7b129908..69145751a 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -2518,7 +2518,7 @@ }, { "name": "find_monitors", - "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\nTo create a cron monitor, use create_monitor.\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", + "description": "List or search Sentry cron monitors, which track scheduled jobs through check-ins.\nFor HTTP uptime checks use find_uptime_monitors; for metric thresholds use find_metric_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- When `hasMore` is true, narrow the results with project, environment, owner, or query filters\n\n\nfind_monitors(organizationSlug='my-organization')\nfind_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/find-monitors.ts b/packages/mcp-core/src/tools/catalog/find-monitors.ts index f38a19d84..406326484 100644 --- a/packages/mcp-core/src/tools/catalog/find-monitors.ts +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -149,8 +149,6 @@ export default defineTool({ "- Check monitor status, owner, project, schedule, or recent environment state", "- When `hasMore` is true, narrow the results with project, environment, owner, or query filters", "", - "To create a cron monitor, use create_monitor.", - "", "", "find_monitors(organizationSlug='my-organization')", "find_monitors(organizationSlug='my-organization', projectSlug='backend', query='billing')", From 40286167c20194716c52eaf3fa583eb28e317540 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:06:05 +0000 Subject: [PATCH 5/6] ref(mcp): Describe create_monitor output fields Add concise descriptions to the non-obvious create_monitor output schema fields and regenerate tool definitions. --- packages/mcp-core/src/toolDefinitions.json | 18 ++++++-- .../src/tools/catalog/create-monitor.ts | 46 ++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 69145751a..30341816e 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1078,10 +1078,12 @@ "type": "object", "properties": { "id": { + "description": "Cron monitor ID.", "type": ["string", "null"] }, "slug": { - "type": "string" + "type": "string", + "description": "Monitor slug that check-ins must use." }, "name": { "type": "string" @@ -1090,38 +1092,48 @@ "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" + "format": "uri", + "description": "Monitor page in Sentry." } }, "required": [ diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.ts b/packages/mcp-core/src/tools/catalog/create-monitor.ts index 85f7738e4..0e0fa03c4 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -28,20 +28,42 @@ const INTERVAL_UNITS = [ export const createMonitorOutputSchema = z.object({ monitor: z.object({ - id: z.string().nullable(), - slug: z.string(), + 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(), - scheduleType: z.string().nullable(), - schedule: z.string().nullable(), - timezone: z.string().nullable(), - checkInMargin: z.number().nullable(), - maxRuntime: z.number().nullable(), - failureIssueThreshold: z.number().nullable(), - recoveryThreshold: z.number().nullable(), - workflowIds: z.array(z.string()), - webUrl: z.string().url(), + 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."), }), }); From 1e87c0e82790d8572eb7be859db39d42f1967a89 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:06:11 +0000 Subject: [PATCH 6/6] fix(mcp): Bound create_monitor workflowIds input Cap the untrusted workflowIds array at 50 entries so oversized input is rejected instead of parsed element by element. --- packages/mcp-core/src/toolDefinitions.json | 1 + .../mcp-core/src/tools/catalog/create-monitor.test.ts | 11 +++++++++++ packages/mcp-core/src/tools/catalog/create-monitor.ts | 3 +++ 3 files changed, 15 insertions(+) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 30341816e..a8fbf83e3 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1061,6 +1061,7 @@ "description": "Owner actor in `user:ID` or `team:ID` format." }, "workflowIds": { + "maxItems": 50, "type": "array", "items": { "type": "string", diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts index 8a2b6e36e..a2312808f 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.test.ts @@ -355,6 +355,17 @@ describe("create_monitor", () => { }, ); + 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, () => diff --git a/packages/mcp-core/src/tools/catalog/create-monitor.ts b/packages/mcp-core/src/tools/catalog/create-monitor.ts index 0e0fa03c4..0da49b09d 100644 --- a/packages/mcp-core/src/tools/catalog/create-monitor.ts +++ b/packages/mcp-core/src/tools/catalog/create-monitor.ts @@ -16,6 +16,8 @@ import { assertProjectRefWithinConstraint } from "./support/project-constraints" 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", @@ -216,6 +218,7 @@ export default defineTool({ .optional(), workflowIds: z .array(z.string().regex(/^\d+$/)) + .max(MAX_WORKFLOW_IDS) .describe("Existing notification Alert IDs to connect.") .optional(), },