Skip to content

Commit a97a7c6

Browse files
feat(crons): Add monitor environment deletion tool (#1443)
Add a catalog-only `delete_monitor_environment` tool so cron cleanup can remove an exact environment from a monitor. The tool requires the organization, project, monitor slug or GUID, and environment name, enforces project constraints, and schedules deletion while preserving the monitor and its other environments. Repeated deletion of an absent environment succeeds. Includes API client support, generated tool definitions, and tests for catalog discovery and dispatch, constraints, exact environment names, and API failures. Validation: build, lint, workspace typechecks, and all 1,763 MCP core tests pass. The full workspace test run encountered 22 failures across four unrelated CLI test files.
1 parent 52fa739 commit a97a7c6

10 files changed

Lines changed: 354 additions & 3 deletions

File tree

‎docs/contributing/tool-responses.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,11 @@ For output-format changes:
263263
If the raw tool result is clean but the agent final answer adds unrelated
264264
content, treat that as a client/agent prompt issue rather than a tool response
265265
formatting issue.
266+
267+
### Cron Monitor Environment Deletion
268+
269+
`delete_monitor_environment` schedules irreversible deletion of one exact
270+
environment from a project-scoped cron monitor. The structured response
271+
identifies the project, monitor, and environment; `success` means the deletion
272+
was accepted or the target was already absent. Deletion completes in the
273+
background, preserving the monitor and its other environments.

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3095,6 +3095,31 @@ export class SentryApiService {
30953095
return MonitorSchema.parse(body);
30963096
}
30973097

3098+
/**
3099+
* Schedule deletion of a cron monitor environment. Returns 202 with no body.
3100+
* Source: src/sentry/monitors/endpoints/project_monitor_environment_details.py
3101+
*/
3102+
async deleteMonitorEnvironment(
3103+
{
3104+
organizationSlug,
3105+
projectSlug,
3106+
monitorSlug,
3107+
environment,
3108+
}: {
3109+
organizationSlug: string;
3110+
projectSlug: string;
3111+
monitorSlug: string;
3112+
environment: string;
3113+
},
3114+
opts?: RequestOptions,
3115+
): Promise<void> {
3116+
await this.request(
3117+
apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`,
3118+
{ method: "DELETE" },
3119+
{ ...opts, allowStatuses: [404] },
3120+
);
3121+
}
3122+
30983123
async listMonitorCheckIns(
30993124
{
31003125
organizationSlug,

‎packages/mcp-core/src/server.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,51 @@ describe("buildServer", () => {
18801880
expect(writes).toEqual(["POST", "PUT", "DELETE"]);
18811881
});
18821882

1883+
it("discovers and dispatches monitor environment deletion with injected constraints", async () => {
1884+
const server = buildServer({
1885+
context: {
1886+
...baseContext,
1887+
grantedSkills: new Set(["project-management"]),
1888+
constraints: {
1889+
organizationSlug: "sentry-mcp-evals",
1890+
projectSlug: "cloudflare-mcp",
1891+
},
1892+
},
1893+
});
1894+
expect(getRegisteredToolNames(server)).not.toContain(
1895+
"delete_monitor_environment",
1896+
);
1897+
const discovered = await callRegisteredTool(
1898+
server,
1899+
"search_sentry_tools",
1900+
{ query: "delete_monitor_environment", limit: 1 },
1901+
);
1902+
expect(getStructuredContent(discovered)).toMatchObject({
1903+
results: [
1904+
{
1905+
name: "delete_monitor_environment",
1906+
inputSchema: { required: ["monitorSlug", "environment"] },
1907+
},
1908+
],
1909+
});
1910+
const result = await callRegisteredTool(server, "execute_sentry_tool", {
1911+
name: "delete_monitor_environment",
1912+
arguments: {
1913+
organizationSlug: "other-org",
1914+
projectSlug: "other-project",
1915+
monitorSlug: "nightly-import",
1916+
environment: "production",
1917+
},
1918+
});
1919+
expect(result.isError).not.toBe(true);
1920+
expect(getStructuredContent(result)).toEqual({
1921+
success: true,
1922+
projectSlug: "cloudflare-mcp",
1923+
monitorSlug: "nightly-import",
1924+
environment: "production",
1925+
});
1926+
});
1927+
18831928
it("execute_sentry_tool dispatches a catalog-only alert update with constrained organization", async () => {
18841929
const server = buildServer({
18851930
context: {

‎packages/mcp-core/src/skillDefinitions.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,10 @@
518518
{
519519
"id": "project-management",
520520
"name": "Manage Projects & Teams",
521-
"description": "Create and modify projects, teams, DSNs, uptime monitors, metric monitors, and alert rules",
521+
"description": "Create and modify projects, teams, DSNs, cron monitor environments, uptime monitors, metric monitors, and alert rules",
522522
"defaultEnabled": false,
523523
"order": 5,
524-
"toolCount": 22,
524+
"toolCount": 23,
525525
"tools": [
526526
{
527527
"name": "add_team_to_project",
@@ -568,6 +568,11 @@
568568
"description": "Permanently delete a Sentry Metric Monitor, preserving its connected Alerts and their other monitors.\nUse get_metric_monitor_details to inspect the monitor and obtain its native monitorId. Legacy metric alert IDs are not monitor IDs.\nUse update_metric_monitor(status='disabled') to pause detection instead. Deletion also removes an associated legacy metric alert and its incident history when present.\nSentry hides the monitor from normal reads immediately and completes deletion in the background.\ndelete_metric_monitor(organizationSlug='my-org', monitorId='12345')",
569569
"requiredScopes": ["org:read", "project:read", "alerts:write"]
570570
},
571+
{
572+
"name": "delete_monitor_environment",
573+
"description": "Delete one environment from a Sentry cron monitor.\n\nUse find_monitors or get_monitor_details to identify the monitor and exact environment name.\n\nDeletion is irreversible and completes in the background. The monitor and its other environments are preserved. An already absent environment is treated as success.\n\n<examples>\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\n</examples>",
574+
"requiredScopes": ["project:write"]
575+
},
571576
{
572577
"name": "delete_uptime_monitor",
573578
"description": "Delete a Sentry HTTP uptime monitor.\n\nUse this tool when you need to permanently remove an uptime monitor.\n\nBe careful when using this tool! Deletion cannot be undone.\n\n<examples>\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n</examples>",

‎packages/mcp-core/src/skills.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const SKILLS: Record<Skill, SkillDefinition> = {
6161
id: "project-management",
6262
name: "Manage Projects & Teams",
6363
description:
64-
"Create and modify projects, teams, DSNs, uptime monitors, metric monitors, and alert rules",
64+
"Create and modify projects, teams, DSNs, cron monitor environments, uptime monitors, metric monitors, and alert rules",
6565
defaultEnabled: false,
6666
order: 5,
6767
},

‎packages/mcp-core/src/toolDefinitions.json‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,74 @@
15741574
"skills": ["project-management"],
15751575
"surface": "catalog"
15761576
},
1577+
{
1578+
"name": "delete_monitor_environment",
1579+
"description": "Delete one environment from a Sentry cron monitor.\n\nUse find_monitors or get_monitor_details to identify the monitor and exact environment name.\n\nDeletion is irreversible and completes in the background. The monitor and its other environments are preserved. An already absent environment is treated as success.\n\n<examples>\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\n</examples>",
1580+
"inputSchema": {
1581+
"type": "object",
1582+
"properties": {
1583+
"organizationSlug": {
1584+
"type": "string",
1585+
"description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool."
1586+
},
1587+
"regionUrl": {
1588+
"default": null,
1589+
"anyOf": [
1590+
{
1591+
"type": "string",
1592+
"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."
1593+
},
1594+
{
1595+
"type": "null"
1596+
}
1597+
]
1598+
},
1599+
"projectSlug": {
1600+
"type": "string",
1601+
"description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool."
1602+
},
1603+
"monitorSlug": {
1604+
"type": "string",
1605+
"minLength": 1,
1606+
"description": "Monitor slug or GUID."
1607+
},
1608+
"environment": {
1609+
"type": "string",
1610+
"minLength": 1,
1611+
"description": "Exact environment name to delete. Preserve whitespace and casing."
1612+
}
1613+
},
1614+
"required": [
1615+
"organizationSlug",
1616+
"projectSlug",
1617+
"monitorSlug",
1618+
"environment"
1619+
]
1620+
},
1621+
"outputSchema": {
1622+
"type": "object",
1623+
"properties": {
1624+
"success": {
1625+
"type": "boolean",
1626+
"const": true
1627+
},
1628+
"monitorSlug": {
1629+
"type": "string"
1630+
},
1631+
"projectSlug": {
1632+
"type": "string"
1633+
},
1634+
"environment": {
1635+
"type": "string"
1636+
}
1637+
},
1638+
"required": ["success", "monitorSlug", "projectSlug", "environment"],
1639+
"additionalProperties": false
1640+
},
1641+
"requiredScopes": ["project:write"],
1642+
"skills": ["project-management"],
1643+
"surface": "catalog"
1644+
},
15771645
{
15781646
"name": "delete_uptime_monitor",
15791647
"description": "Delete a Sentry HTTP uptime monitor.\n\nUse this tool when you need to permanently remove an uptime monitor.\n\nBe careful when using this tool! Deletion cannot be undone.\n\n<examples>\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n</examples>",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { mswServer } from "@sentry/mcp-server-mocks";
2+
import { HttpResponse, http } from "msw";
3+
import { z } from "zod";
4+
import { describe, expect, it, vi } from "vitest";
5+
import {
6+
assertStructuredOnlyResult,
7+
getStructuredContent,
8+
} from "../../test-utils/structured-content.js";
9+
import deleteMonitorEnvironment, {
10+
deleteMonitorEnvironmentOutputSchema,
11+
} from "./delete-monitor-environment.js";
12+
13+
const context = {
14+
constraints: { organizationSlug: null },
15+
accessToken: "access-token",
16+
userId: "1",
17+
};
18+
const params = {
19+
organizationSlug: "sentry-mcp-evals",
20+
regionUrl: null,
21+
projectSlug: "cloudflare-mcp",
22+
monitorSlug: "nightly-import",
23+
environment: "production",
24+
};
25+
const endpoint =
26+
"https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/environments/production/";
27+
28+
describe("delete_monitor_environment", () => {
29+
it("schedules deletion of an exact monitor environment", async () => {
30+
const result = await deleteMonitorEnvironment.handler(params, context);
31+
assertStructuredOnlyResult(result);
32+
const content = getStructuredContent(result);
33+
expect(deleteMonitorEnvironmentOutputSchema.parse(content)).toEqual(
34+
content,
35+
);
36+
expect(content).toMatchInlineSnapshot(`
37+
{
38+
"environment": "production",
39+
"monitorSlug": "nightly-import",
40+
"projectSlug": "cloudflare-mcp",
41+
"success": true,
42+
}
43+
`);
44+
});
45+
46+
it("treats an absent or pending deletion environment as success", async () => {
47+
mswServer.use(
48+
http.delete(endpoint, () => new HttpResponse(null, { status: 404 })),
49+
);
50+
const result = await deleteMonitorEnvironment.handler(params, context);
51+
expect(getStructuredContent(result)).toEqual({
52+
success: true,
53+
monitorSlug: params.monitorSlug,
54+
projectSlug: params.projectSlug,
55+
environment: params.environment,
56+
});
57+
});
58+
59+
it.each([403, 500])("propagates HTTP %s failures", async (status) => {
60+
mswServer.use(
61+
http.delete(endpoint, () =>
62+
HttpResponse.json({ detail: "Request failed" }, { status }),
63+
),
64+
);
65+
await expect(
66+
deleteMonitorEnvironment.handler(params, context),
67+
).rejects.toThrow();
68+
});
69+
70+
it("rejects a project outside the active constraint before deletion", async () => {
71+
const remove = vi.fn(() => new HttpResponse(null, { status: 202 }));
72+
mswServer.use(http.delete(endpoint, remove));
73+
await expect(
74+
deleteMonitorEnvironment.handler(params, {
75+
...context,
76+
constraints: { organizationSlug: null, projectSlug: "other-project" },
77+
}),
78+
).rejects.toThrow("outside the active project constraint");
79+
expect(remove).not.toHaveBeenCalled();
80+
});
81+
82+
it("preserves exact environment names and encodes path delimiters", async () => {
83+
const environment = " Production #? ";
84+
const remove = vi.fn(({ request }: { request: Request }) => {
85+
expect(request.url).toBe(
86+
`${endpoint.slice(0, -"production/".length)}${encodeURIComponent(environment)}/`,
87+
);
88+
return new HttpResponse(null, { status: 202 });
89+
});
90+
mswServer.use(
91+
http.delete(
92+
"https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/environments/:environment/",
93+
remove,
94+
),
95+
);
96+
const parsed = z
97+
.object(deleteMonitorEnvironment.inputSchema)
98+
.parse({ ...params, environment });
99+
const result = await deleteMonitorEnvironment.handler(parsed, context);
100+
expect(getStructuredContent(result)).toMatchObject({ environment });
101+
expect(remove).toHaveBeenCalledOnce();
102+
});
103+
104+
it("requires a nonempty environment", () => {
105+
expect(
106+
z
107+
.object(deleteMonitorEnvironment.inputSchema)
108+
.safeParse({ ...params, environment: "" }).success,
109+
).toBe(false);
110+
});
111+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { z } from "zod";
2+
import { apiServiceFromContext } from "../../internal/tool-helpers/api";
3+
import { defineTool } from "../../internal/tool-helpers/define";
4+
import { structuredResult } from "../../internal/tool-helpers/results";
5+
import {
6+
ParamOrganizationSlug,
7+
ParamProjectSlug,
8+
ParamRegionUrl,
9+
} from "../../schema";
10+
import { setTargetTagsAndAttributes } from "../../telem/scope";
11+
import type { ServerContext } from "../../types";
12+
import { assertProjectRefWithinConstraint } from "./support/project-constraints";
13+
14+
export const deleteMonitorEnvironmentOutputSchema = z.object({
15+
success: z.literal(true),
16+
monitorSlug: z.string(),
17+
projectSlug: z.string(),
18+
environment: z.string(),
19+
});
20+
21+
export default defineTool({
22+
name: "delete_monitor_environment",
23+
skills: ["project-management"],
24+
requiredScopes: ["project:write"],
25+
description: [
26+
"Delete one environment from a Sentry cron monitor.",
27+
"",
28+
"Use find_monitors or get_monitor_details to identify the monitor and exact environment name.",
29+
"",
30+
"Deletion is irreversible and completes in the background. The monitor and its other environments are preserved. An already absent environment is treated as success.",
31+
"",
32+
"<examples>",
33+
"delete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')",
34+
"</examples>",
35+
].join("\n"),
36+
inputSchema: {
37+
organizationSlug: ParamOrganizationSlug,
38+
regionUrl: ParamRegionUrl.nullable().default(null),
39+
projectSlug: ParamProjectSlug,
40+
monitorSlug: z.string().trim().min(1).describe("Monitor slug or GUID."),
41+
environment: z
42+
.string()
43+
.min(1)
44+
.describe(
45+
"Exact environment name to delete. Preserve whitespace and casing.",
46+
),
47+
},
48+
annotations: {
49+
readOnlyHint: false,
50+
destructiveHint: true,
51+
idempotentHint: true,
52+
openWorldHint: true,
53+
},
54+
outputSchema: deleteMonitorEnvironmentOutputSchema,
55+
async handler(params, context: ServerContext) {
56+
const apiService = apiServiceFromContext(context, {
57+
regionUrl: params.regionUrl ?? undefined,
58+
});
59+
const organizationSlug = params.organizationSlug;
60+
setTargetTagsAndAttributes(params);
61+
62+
assertProjectRefWithinConstraint({
63+
resourceLabel: "Cron monitor",
64+
scopedProjectSlug: context.constraints.projectSlug,
65+
project: { slug: params.projectSlug },
66+
});
67+
68+
await apiService.deleteMonitorEnvironment({
69+
organizationSlug,
70+
projectSlug: params.projectSlug,
71+
monitorSlug: params.monitorSlug,
72+
environment: params.environment,
73+
});
74+
75+
return structuredResult({
76+
success: true as const,
77+
environment: params.environment,
78+
monitorSlug: params.monitorSlug,
79+
projectSlug: params.projectSlug,
80+
});
81+
},
82+
});

0 commit comments

Comments
 (0)