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