From 27d2c34428149101fd9d7e3f3062a1a7eebf23c4 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 11:34:27 -0800 Subject: [PATCH] fix(uptime): Explain seat errors when enabling a monitor When an organization has no seat or budget, update_uptime_monitor returned the raw API body and agents kept retrying. Tell them the monitor stays disabled and what has to change first. --- .../catalog/update-uptime-monitor.test.ts | 62 ++++++++++++++++++ .../tools/catalog/update-uptime-monitor.ts | 65 +++++++++++++------ 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts b/packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts index 21e02cbef..16feb51c7 100644 --- a/packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts @@ -1,6 +1,8 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; +import { ApiValidationError } from "../../api-client/index.js"; +import { UserInputError } from "../../errors.js"; import { assertStructuredOnlyResult, getStructuredContent, @@ -62,6 +64,66 @@ describe("update_uptime_monitor", () => { `); }); + it("explains how to get a seat when enabling fails", async () => { + mswServer.use( + http.put( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/", + () => + HttpResponse.json( + { + status: [ + "You don't have enough pay-as-you-go available to create a new seat", + ], + }, + { status: 400 }, + ), + ), + ); + + const error = await updateUptimeMonitor + .handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + uptimeMonitorId: "4509100000001001", + status: "active", + }, + context, + ) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(UserInputError); + expect((error as Error).message).toContain( + "You don't have enough pay-as-you-go available to create a new seat", + ); + expect((error as Error).message).toContain("Do not retry"); + }); + + it("does not treat other validation errors as seat errors", async () => { + mswServer.use( + http.put( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/", + () => HttpResponse.json({ url: ["Invalid URL"] }, { status: 400 }), + ), + ); + + const error = await updateUptimeMonitor + .handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlug: "cloudflare-mcp", + uptimeMonitorId: "4509100000001001", + status: "active", + }, + context, + ) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiValidationError); + }); + it("clears owner when null is provided", async () => { let requestBody: Record | null = null; mswServer.use( diff --git a/packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts b/packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts index 5be848f6a..39516041c 100644 --- a/packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts +++ b/packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ApiValidationError } from "../../api-client/index"; import { UserInputError } from "../../errors"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; @@ -27,6 +28,21 @@ export const updateUptimeMonitorOutputSchema = z.object({ monitor: uptimeMonitorSummarySchema, }); +const seatErrorSchema = z.object({ status: z.array(z.string()).min(1) }); + +// The API client only keeps a field error's body as raw text in `detail`. +function getSeatErrorReason(error: unknown): string | undefined { + if (!(error instanceof ApiValidationError) || !error.detail) { + return undefined; + } + try { + const parsed = seatErrorSchema.safeParse(JSON.parse(error.detail)); + return parsed.success ? parsed.data.status[0] : undefined; + } catch { + return undefined; + } +} + function hasDefinedUpdate( value: unknown, ): value is Exclude { @@ -138,25 +154,36 @@ export default defineTool({ ); } - const monitor = await apiService.updateUptimeMonitor({ - organizationSlug, - projectSlug: params.projectSlug, - uptimeMonitorId: params.uptimeMonitorId, - name: params.name, - url: params.url, - intervalSeconds: params.intervalSeconds, - timeoutMs: params.timeoutMs, - method: params.method, - headers: params.headers && toUptimeHeaderPairs(params.headers), - body: params.body, - status: params.status, - owner: params.owner, - environment: params.environment, - traceSampling: params.traceSampling, - responseCaptureEnabled: params.responseCaptureEnabled, - recoveryThreshold: params.recoveryThreshold, - downtimeThreshold: params.downtimeThreshold, - }); + const monitor = await apiService + .updateUptimeMonitor({ + organizationSlug, + projectSlug: params.projectSlug, + uptimeMonitorId: params.uptimeMonitorId, + name: params.name, + url: params.url, + intervalSeconds: params.intervalSeconds, + timeoutMs: params.timeoutMs, + method: params.method, + headers: params.headers && toUptimeHeaderPairs(params.headers), + body: params.body, + status: params.status, + owner: params.owner, + environment: params.environment, + traceSampling: params.traceSampling, + responseCaptureEnabled: params.responseCaptureEnabled, + recoveryThreshold: params.recoveryThreshold, + downtimeThreshold: params.downtimeThreshold, + }) + .catch((error: unknown) => { + const reason = params.status === "active" && getSeatErrorReason(error); + if (reason) { + throw new UserInputError( + `Could not enable this uptime monitor: ${reason}. The monitor is still disabled. Do not retry until a billing admin raises the organization's pay-as-you-go budget in Sentry's billing settings, or another uptime monitor is disabled.`, + { cause: error }, + ); + } + throw error; + }); return structuredResult({ monitor: toUptimeMonitorSummary(