Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/contributing/tool-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.request(
apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`,
{ method: "DELETE" },
{ ...opts, allowStatuses: [404] },
);
}

async listMonitorCheckIns(
{
organizationSlug,
Expand Down
45 changes: 45 additions & 0 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 7 additions & 2 deletions packages/mcp-core/src/skillDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<examples>\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\n</examples>",
"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<examples>\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n</examples>",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-core/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const SKILLS: Record<Skill, SkillDefinition> = {
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,
},
Expand Down
68 changes: 68 additions & 0 deletions packages/mcp-core/src/toolDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<examples>\ndelete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')\n</examples>",
"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<examples>\ndelete_uptime_monitor(organizationSlug='my-organization', projectSlug='backend', uptimeMonitorId='12345')\n</examples>",
Expand Down
111 changes: 111 additions & 0 deletions packages/mcp-core/src/tools/catalog/delete-monitor-environment.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
82 changes: 82 additions & 0 deletions packages/mcp-core/src/tools/catalog/delete-monitor-environment.ts
Original file line number Diff line number Diff line change
@@ -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.",
"",
"<examples>",
"delete_monitor_environment(organizationSlug='my-organization', projectSlug='backend', monitorSlug='nightly-import', environment='production')",
"</examples>",
].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,
});
},
});
Loading
Loading