From e18bd0ec9fbc8e4b70ce23479bcc2b296a4f828e Mon Sep 17 00:00:00 2001 From: Alec Gutman Date: Mon, 2 Nov 2026 21:08:32 -0800 Subject: [PATCH 1/2] 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 --- 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 + 8 files changed, 735 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 988733871..c11f5fd5e 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2945,6 +2945,61 @@ export class SentryApiService { return MonitorSchema.parse(body); } + /** + * 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 1e7418d73..f8ffc9695 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -29,7 +29,7 @@ }, { "name": "find_monitors", - "description": "Find Sentry cron monitors.\n\nUse this tool when you need to:\n- List cron monitors in an organization\n- Find a monitor by name or slug before getting details\n- Check monitor status, owner, project, schedule, or recent environment state\n- 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": "Find Sentry cron monitors.\n\nUse this tool when you need to:\n- List cron monitors in an organization\n- Find a monitor by name or slug before getting details\n- Check monitor status, owner, project, schedule, or recent environment state\n- 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 with Static, Percent or Dynamic detection in a project.\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 da1ef1fa0..e873c55e2 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -975,6 +975,178 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "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": "catalog" + }, { "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", @@ -2350,7 +2522,7 @@ }, { "name": "find_monitors", - "description": "Find Sentry cron monitors.\n\nUse this tool when you need to:\n- List cron monitors in an organization\n- Find a monitor by name or slug before getting details\n- Check monitor status, owner, project, schedule, or recent environment state\n- 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": "Find Sentry cron monitors.\n\nUse this tool when you need to:\n- List cron monitors in an organization\n- Find a monitor by name or slug before getting details\n- Check monitor status, owner, project, schedule, or recent environment state\n- 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 fcc88c217..504219a83 100644 --- a/packages/mcp-core/src/tools/catalog/find-monitors.ts +++ b/packages/mcp-core/src/tools/catalog/find-monitors.ts @@ -148,6 +148,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 0b47f82f1..50f57ea83 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"; @@ -116,6 +117,7 @@ const catalogTools = { 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, From 4d11388258df0a7cd83bd28ccf060667c186b217 Mon Sep 17 00:00:00 2001 From: Alec Gutman Date: Mon, 2 Nov 2026 21:59:08 -0800 Subject: [PATCH 2/2] 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 --- 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 f8ffc9695..29efc44d9 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 e873c55e2..b4c883b43 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -977,7 +977,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": { @@ -1012,26 +1012,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", @@ -1040,9 +1060,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", @@ -1073,7 +1093,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],