From 431f6a714fd3158f823275fbdf6259e50fd0267c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:27:44 +0000 Subject: [PATCH] feat(uptime): Create uptime monitors through the detectors endpoint The legacy POST /projects/{org}/{project}/uptime/ endpoint does not record who created the monitor, so MCP-created monitors show up with no creator. Create them through the project detectors endpoint the Sentry UI uses, which records the acting user, then return the monitor in the existing uptime shape. Co-Authored-By: Matt Duncan <14761+mrduncan@users.noreply.github.com> --- packages/mcp-core/src/api-client/client.ts | 67 ++++++--- .../catalog/create-uptime-monitor.test.ts | 138 +++++++++++++----- packages/mcp-server-mocks/src/index.ts | 36 +++-- 3 files changed, 169 insertions(+), 72 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 988733871..05b2e441a 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3200,9 +3200,16 @@ export class SentryApiService { /** * Create an uptime monitor. * - * POST /projects/{org}/{project}/uptime/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_index.py - * Body fields verified from UptimeMonitorValidator (camelCase via CamelSnakeSerializer). + * POST /organizations/{org}/projects/{project}/detectors/ + * Source: src/sentry/uptime/endpoints/validators.py (UptimeDomainCheckFailureValidator) + * + * Uses the detectors endpoint, the same one the Sentry UI uses, so Sentry + * records the acting user as the monitor's creator. The legacy + * POST /projects/{org}/{project}/uptime/ endpoint does not. + * + * The detector validator always enables new uptime detectors, so a + * `disabled` status is applied with a follow-up update. The monitor is then + * returned in the uptime monitor shape the other uptime tools use. */ async createUptimeMonitor( { @@ -3244,39 +3251,63 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const payload: Record = { - name, + const dataSource: Record = { url, intervalSeconds, timeoutMs, }; - if (method !== undefined) payload.method = method; - if (headers !== undefined) payload.headers = headers; - if (body !== undefined) payload.body = body; - if (assertion !== undefined) payload.assertion = assertion; - if (status !== undefined) payload.status = status; - if (owner !== undefined) payload.owner = owner; - if (environment !== undefined) payload.environment = environment; - if (traceSampling !== undefined) payload.traceSampling = traceSampling; + if (method !== undefined) dataSource.method = method; + if (headers !== undefined) dataSource.headers = headers; + if (body !== undefined) dataSource.body = body; + if (assertion !== undefined) dataSource.assertion = assertion; + if (traceSampling !== undefined) dataSource.traceSampling = traceSampling; if (responseCaptureEnabled !== undefined) { - payload.responseCaptureEnabled = responseCaptureEnabled; + dataSource.responseCaptureEnabled = responseCaptureEnabled; } + + const config: Record = {}; + if (environment !== undefined) config.environment = environment; if (recoveryThreshold !== undefined) { - payload.recoveryThreshold = recoveryThreshold; + config.recoveryThreshold = recoveryThreshold; } if (downtimeThreshold !== undefined) { - payload.downtimeThreshold = downtimeThreshold; + config.downtimeThreshold = downtimeThreshold; } + const payload: Record = { + type: "uptime_domain_failure", + name, + dataSources: [dataSource], + config, + }; + if (owner !== undefined) payload.owner = owner; + const responseBody = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/`, + apiPath`/organizations/${organizationSlug}/projects/${projectSlug}/detectors/`, { method: "POST", body: JSON.stringify(payload), }, opts, ); - return UptimeMonitorSchema.parse(responseBody); + const detector = DetectorSchema.parse(responseBody); + + if (status === "disabled") { + return await this.updateUptimeMonitor( + { + organizationSlug, + projectSlug, + uptimeMonitorId: detector.id, + status, + }, + opts, + ); + } + + return await this.getUptimeMonitorDetails( + { organizationSlug, projectSlug, uptimeMonitorId: detector.id }, + opts, + ); } /** diff --git a/packages/mcp-core/src/tools/catalog/create-uptime-monitor.test.ts b/packages/mcp-core/src/tools/catalog/create-uptime-monitor.test.ts index 9c59ab45e..c46ce8c9f 100644 --- a/packages/mcp-core/src/tools/catalog/create-uptime-monitor.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-uptime-monitor.test.ts @@ -18,39 +18,55 @@ const context = { }; describe("create_uptime_monitor", () => { - it("creates an uptime monitor with camelCase payload", async () => { + const detectorsUrl = + "https://sentry.io/api/0/organizations/sentry-mcp-evals/projects/cloudflare-mcp/detectors/"; + const monitorUrl = + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001002/"; + const createdDetector = { + id: "4509100000001002", + projectId: "4509062593708032", + name: "Checkout Health", + type: "uptime_domain_failure", + enabled: true, + config: { mode: 1, environment: "production" }, + conditionGroup: null, + dataSources: null, + workflowIds: [], + owner: null, + createdBy: "1", + dateCreated: "2026-11-02T00:00:00.000000Z", + dateUpdated: "2026-11-02T00:00:00.000000Z", + }; + const savedMonitor = { + id: "4509100000001002", + projectSlug: "cloudflare-mcp", + environment: "production", + name: "Checkout Health", + status: "active", + uptimeStatus: 1, + mode: 1, + owner: null, + recoveryThreshold: 1, + downtimeThreshold: 3, + url: "https://example.com/checkout", + method: "GET", + body: null, + headers: [], + intervalSeconds: 300, + timeoutMs: 8000, + traceSampling: false, + responseCaptureEnabled: true, + assertion: null, + }; + + it("creates the monitor through the detectors endpoint", async () => { let requestBody: Record | null = null; mswServer.use( - http.post( - "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/", - async ({ request }) => { - requestBody = (await request.json()) as Record; - return HttpResponse.json( - { - id: "4509100000001002", - projectSlug: "cloudflare-mcp", - environment: "production", - name: "Checkout Health", - status: "active", - uptimeStatus: 1, - mode: 1, - owner: null, - recoveryThreshold: 1, - downtimeThreshold: 3, - url: "https://example.com/checkout", - method: "GET", - body: null, - headers: [], - intervalSeconds: 300, - timeoutMs: 8000, - traceSampling: false, - responseCaptureEnabled: true, - assertion: null, - }, - { status: 201 }, - ); - }, - ), + http.post(detectorsUrl, async ({ request }) => { + requestBody = (await request.json()) as Record; + return HttpResponse.json(createdDetector, { status: 201 }); + }), + http.get(monitorUrl, () => HttpResponse.json(savedMonitor)), ); const result = await createUptimeMonitor.handler( @@ -64,21 +80,27 @@ describe("create_uptime_monitor", () => { timeoutMs: 8000, method: "GET", environment: "production", + downtimeThreshold: 3, }, context, ); - expect(requestBody).toMatchObject({ + expect(requestBody).toEqual({ + type: "uptime_domain_failure", name: "Checkout Health", - url: "https://example.com/checkout", - intervalSeconds: 300, - timeoutMs: 8000, - method: "GET", - environment: "production", + dataSources: [ + { + url: "https://example.com/checkout", + intervalSeconds: 300, + timeoutMs: 8000, + method: "GET", + }, + ], + config: { + environment: "production", + downtimeThreshold: 3, + }, }); - expect(requestBody).not.toHaveProperty("interval_seconds"); - expect(requestBody).not.toHaveProperty("timeout_ms"); - expect(requestBody).not.toHaveProperty("assertion"); assertStructuredOnlyResult(result); const structuredContent = getStructuredContent(result); @@ -108,6 +130,42 @@ describe("create_uptime_monitor", () => { `); }); + it("disables the monitor after creation when status is disabled", async () => { + let createBody: Record | null = null; + let updateBody: Record | null = null; + mswServer.use( + http.post(detectorsUrl, async ({ request }) => { + createBody = (await request.json()) as Record; + return HttpResponse.json(createdDetector, { status: 201 }); + }), + http.put(monitorUrl, async ({ request }) => { + updateBody = (await request.json()) as Record; + return HttpResponse.json({ ...savedMonitor, status: "disabled" }); + }), + ); + + const result = await createUptimeMonitor.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + name: "Checkout Health", + url: "https://example.com/checkout", + intervalSeconds: 300, + timeoutMs: 8000, + status: "disabled", + }, + context, + ); + + expect(createBody).not.toHaveProperty("status"); + expect(createBody).not.toHaveProperty("enabled"); + expect(updateBody).toEqual({ status: "disabled" }); + expect(getStructuredContent(result)).toMatchObject({ + monitor: { id: "4509100000001002", status: "disabled" }, + }); + }); + it("rejects empty owner in the input schema", () => { const result = createUptimeMonitor.inputSchema.owner.safeParse(" "); expect(result.success).toBe(false); diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index ceff40cbe..e99916d88 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -975,30 +975,38 @@ export const restHandlers = buildHandlers([ }, { method: "post", - path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/", + path: "/api/0/organizations/sentry-mcp-evals/projects/cloudflare-mcp/detectors/", fetch: async ({ request }) => { const body = (await request.json()) as Record; return HttpResponse.json( { - ...uptimeMonitorFixture, - ...body, id: "4509100000001002", - projectSlug: "cloudflare-mcp", - status: body.status ?? "active", - uptimeStatus: 1, - method: body.method ?? "GET", - headers: body.headers ?? [], - body: body.body ?? null, - assertion: body.assertion ?? null, - recoveryThreshold: body.recoveryThreshold ?? 1, - downtimeThreshold: body.downtimeThreshold ?? 3, - traceSampling: body.traceSampling ?? false, - responseCaptureEnabled: body.responseCaptureEnabled ?? true, + projectId: "4509062593708032", + name: body.name, + type: body.type, + enabled: true, + config: { mode: 1, ...(body.config as Record) }, + conditionGroup: null, + dataSources: null, + workflowIds: [], + owner: body.owner ?? null, + createdBy: "1", + dateCreated: "2026-11-02T00:00:00.000000Z", + dateUpdated: "2026-11-02T00:00:00.000000Z", }, { status: 201 }, ); }, }, + { + method: "get", + path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001002/", + fetch: () => + HttpResponse.json({ + ...uptimeMonitorFixture, + id: "4509100000001002", + }), + }, { method: "put", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/",