diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 8910d99a2..2805c164c 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -263,3 +263,11 @@ For output-format changes: If the raw tool result is clean but the agent final answer adds unrelated content, treat that as a client/agent prompt issue rather than a tool response formatting issue. + +### Cron Monitor Environment Deletion + +`delete_monitor_environment` schedules irreversible deletion of one exact +environment from a project-scoped cron monitor. The structured response +identifies the project, monitor, and environment; `success` means the deletion +was accepted or the target was already absent. Deletion completes in the +background, preserving the monitor and its other environments. diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 63de04ea0..3842b2a49 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3095,6 +3095,31 @@ export class SentryApiService { 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 + */ + async deleteMonitorEnvironment( + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + }: { + organizationSlug: string; + projectSlug: string; + monitorSlug: string; + environment: string; + }, + opts?: RequestOptions, + ): Promise { + await this.request( + apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`, + { method: "DELETE" }, + { ...opts, allowStatuses: [404] }, + ); + } + async listMonitorCheckIns( { organizationSlug, diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index bce1ba369..061ac44b9 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -1880,6 +1880,51 @@ describe("buildServer", () => { expect(writes).toEqual(["POST", "PUT", "DELETE"]); }); + it("discovers and dispatches monitor environment deletion with injected constraints", async () => { + const server = buildServer({ + context: { + ...baseContext, + grantedSkills: new Set(["project-management"]), + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + }, + }, + }); + expect(getRegisteredToolNames(server)).not.toContain( + "delete_monitor_environment", + ); + const discovered = await callRegisteredTool( + server, + "search_sentry_tools", + { query: "delete_monitor_environment", limit: 1 }, + ); + expect(getStructuredContent(discovered)).toMatchObject({ + results: [ + { + name: "delete_monitor_environment", + inputSchema: { required: ["monitorSlug", "environment"] }, + }, + ], + }); + const result = await callRegisteredTool(server, "execute_sentry_tool", { + name: "delete_monitor_environment", + arguments: { + organizationSlug: "other-org", + projectSlug: "other-project", + monitorSlug: "nightly-import", + environment: "production", + }, + }); + expect(result.isError).not.toBe(true); + expect(getStructuredContent(result)).toEqual({ + success: true, + projectSlug: "cloudflare-mcp", + monitorSlug: "nightly-import", + environment: "production", + }); + }); + it("execute_sentry_tool dispatches a catalog-only alert update with constrained organization", async () => { const server = buildServer({ context: { diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 39b74539a..2a8a8e62d 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -518,10 +518,10 @@ { "id": "project-management", "name": "Manage Projects & Teams", - "description": "Create and modify projects, teams, DSNs, uptime monitors, metric monitors, and alert rules", + "description": "Create and modify projects, teams, DSNs, cron monitor environments, uptime monitors, metric monitors, and alert rules", "defaultEnabled": false, "order": 5, - "toolCount": 22, + "toolCount": 23, "tools": [ { "name": "add_team_to_project", @@ -568,6 +568,11 @@ "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')", "requiredScopes": ["org:read", "project:read", "alerts:write"] }, + { + "name": "delete_monitor_environment", + "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\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\n", + "requiredScopes": ["project:write"] + }, { "name": "delete_uptime_monitor", "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\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n", diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index 817c1d428..dd89d52b3 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, uptime monitors, metric monitors, and alert rules", + "Create and modify projects, teams, DSNs, cron monitor 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 63709f5c6..76a29a173 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1574,6 +1574,74 @@ "skills": ["project-management"], "surface": "catalog" }, + { + "name": "delete_monitor_environment", + "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\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\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." + }, + "monitorSlug": { + "type": "string", + "minLength": 1, + "description": "Monitor slug or GUID." + }, + "environment": { + "type": "string", + "minLength": 1, + "description": "Exact environment name to delete. Preserve whitespace and casing." + } + }, + "required": [ + "organizationSlug", + "projectSlug", + "monitorSlug", + "environment" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "monitorSlug": { + "type": "string" + }, + "projectSlug": { + "type": "string" + }, + "environment": { + "type": "string" + } + }, + "required": ["success", "monitorSlug", "projectSlug", "environment"], + "additionalProperties": false + }, + "requiredScopes": ["project:write"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "delete_uptime_monitor", "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\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n", diff --git a/packages/mcp-core/src/tools/catalog/delete-monitor-environment.test.ts b/packages/mcp-core/src/tools/catalog/delete-monitor-environment.test.ts new file mode 100644 index 000000000..fc323c31c --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/delete-monitor-environment.test.ts @@ -0,0 +1,111 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { z } from "zod"; +import { describe, expect, it, vi } from "vitest"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import deleteMonitorEnvironment, { + deleteMonitorEnvironmentOutputSchema, +} from "./delete-monitor-environment.js"; + +const context = { + constraints: { organizationSlug: null }, + accessToken: "access-token", + userId: "1", +}; +const params = { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + monitorSlug: "nightly-import", + environment: "production", +}; +const endpoint = + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/environments/production/"; + +describe("delete_monitor_environment", () => { + it("schedules deletion of an exact monitor environment", async () => { + const result = await deleteMonitorEnvironment.handler(params, context); + assertStructuredOnlyResult(result); + const content = getStructuredContent(result); + expect(deleteMonitorEnvironmentOutputSchema.parse(content)).toEqual( + content, + ); + expect(content).toMatchInlineSnapshot(` + { + "environment": "production", + "monitorSlug": "nightly-import", + "projectSlug": "cloudflare-mcp", + "success": true, + } + `); + }); + + it("treats an absent or pending deletion environment as success", async () => { + mswServer.use( + http.delete(endpoint, () => new HttpResponse(null, { status: 404 })), + ); + const result = await deleteMonitorEnvironment.handler(params, context); + expect(getStructuredContent(result)).toEqual({ + success: true, + monitorSlug: params.monitorSlug, + projectSlug: params.projectSlug, + environment: params.environment, + }); + }); + + it.each([403, 500])("propagates HTTP %s failures", async (status) => { + mswServer.use( + http.delete(endpoint, () => + HttpResponse.json({ detail: "Request failed" }, { status }), + ), + ); + await expect( + deleteMonitorEnvironment.handler(params, context), + ).rejects.toThrow(); + }); + + it("rejects a project outside the active constraint before deletion", async () => { + const remove = vi.fn(() => new HttpResponse(null, { status: 202 })); + mswServer.use(http.delete(endpoint, remove)); + await expect( + deleteMonitorEnvironment.handler(params, { + ...context, + constraints: { organizationSlug: null, projectSlug: "other-project" }, + }), + ).rejects.toThrow("outside the active project constraint"); + expect(remove).not.toHaveBeenCalled(); + }); + + it("preserves exact environment names and encodes path delimiters", async () => { + const environment = " Production #? "; + const remove = vi.fn(({ request }: { request: Request }) => { + expect(request.url).toBe( + `${endpoint.slice(0, -"production/".length)}${encodeURIComponent(environment)}/`, + ); + return new HttpResponse(null, { status: 202 }); + }); + mswServer.use( + http.delete( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/environments/:environment/", + remove, + ), + ); + const parsed = z + .object(deleteMonitorEnvironment.inputSchema) + .parse({ ...params, environment }); + const result = await deleteMonitorEnvironment.handler(parsed, context); + expect(getStructuredContent(result)).toMatchObject({ environment }); + expect(remove).toHaveBeenCalledOnce(); + }); + + it("requires a nonempty environment", () => { + expect( + z + .object(deleteMonitorEnvironment.inputSchema) + .safeParse({ ...params, environment: "" }).success, + ).toBe(false); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/delete-monitor-environment.ts b/packages/mcp-core/src/tools/catalog/delete-monitor-environment.ts new file mode 100644 index 000000000..5bc297074 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/delete-monitor-environment.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +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"; + +export const deleteMonitorEnvironmentOutputSchema = z.object({ + success: z.literal(true), + monitorSlug: z.string(), + projectSlug: z.string(), + environment: z.string(), +}); + +export default defineTool({ + name: "delete_monitor_environment", + skills: ["project-management"], + requiredScopes: ["project:write"], + description: [ + "Delete one environment from a Sentry cron monitor.", + "", + "Use find_monitors or get_monitor_details to identify the monitor and exact environment name.", + "", + "Deletion is irreversible and completes in the background. The monitor and its other environments are preserved. An already absent environment is treated as success.", + "", + "", + "delete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + monitorSlug: z.string().trim().min(1).describe("Monitor slug or GUID."), + environment: z + .string() + .min(1) + .describe( + "Exact environment name to delete. Preserve whitespace and casing.", + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + outputSchema: deleteMonitorEnvironmentOutputSchema, + async handler(params, context: ServerContext) { + 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 }, + }); + + await apiService.deleteMonitorEnvironment({ + organizationSlug, + projectSlug: params.projectSlug, + monitorSlug: params.monitorSlug, + environment: params.environment, + }); + + return structuredResult({ + success: true as const, + environment: params.environment, + monitorSlug: params.monitorSlug, + projectSlug: params.projectSlug, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index be6697cb3..a08aec24a 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 deleteMonitorEnvironment from "./delete-monitor-environment"; import findUptimeMonitors from "./find-uptime-monitors"; import getUptimeMonitorDetails from "./get-uptime-monitor-details"; import findMetricMonitors from "./find-metric-monitors"; @@ -113,6 +114,7 @@ const catalogTools = { get_dashboard_details: getDashboardDetails, find_monitors: findMonitors, get_monitor_details: getMonitorDetails, + delete_monitor_environment: deleteMonitorEnvironment, find_uptime_monitors: findUptimeMonitors, get_uptime_monitor_details: getUptimeMonitorDetails, find_metric_monitors: findMetricMonitors, diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index b3376a3c2..0f8bcea9d 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1095,6 +1095,11 @@ export const restHandlers = buildHandlers([ path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/", fetch: () => new HttpResponse(null, { status: 202 }), }, + { + method: "delete", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/environments/production/", + fetch: () => new HttpResponse(null, { status: 202 }), + }, { method: "get", path: "/api/0/organizations/sentry-mcp-evals/monitors/",