Skip to content

Commit 157edb3

Browse files
authored
fix(uptime): Explain seat errors when enabling a monitor (#2120)
Enabling an uptime monitor fails with a field error when the organization has no seat or pay-as-you-go budget. `update_uptime_monitor` passed that error through as a raw JSON body, and these failures are a large share of the tool's 400s. The tool now returns the server's reason, says that the monitor is still disabled, and tells the agent not to retry until the budget changes or another monitor is disabled. Other validation errors are unchanged. The check reads `detail` because `@sentry/api` drops the parsed body for errors that have no `detail` field.
1 parent 12be99c commit 157edb3

2 files changed

Lines changed: 108 additions & 19 deletions

File tree

‎packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { mswServer } from "@sentry/mcp-server-mocks";
22
import { HttpResponse, http } from "msw";
33
import { describe, expect, it } from "vitest";
4+
import { ApiValidationError } from "../../api-client/index.js";
5+
import { UserInputError } from "../../errors.js";
46
import {
57
assertStructuredOnlyResult,
68
getStructuredContent,
@@ -62,6 +64,66 @@ describe("update_uptime_monitor", () => {
6264
`);
6365
});
6466

67+
it("explains how to get a seat when enabling fails", async () => {
68+
mswServer.use(
69+
http.put(
70+
"https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/",
71+
() =>
72+
HttpResponse.json(
73+
{
74+
status: [
75+
"You don't have enough pay-as-you-go available to create a new seat",
76+
],
77+
},
78+
{ status: 400 },
79+
),
80+
),
81+
);
82+
83+
const error = await updateUptimeMonitor
84+
.handler(
85+
{
86+
organizationSlug: "sentry-mcp-evals",
87+
regionUrl: null,
88+
projectSlug: "cloudflare-mcp",
89+
uptimeMonitorId: "4509100000001001",
90+
status: "active",
91+
},
92+
context,
93+
)
94+
.catch((e: unknown) => e);
95+
96+
expect(error).toBeInstanceOf(UserInputError);
97+
expect((error as Error).message).toContain(
98+
"You don't have enough pay-as-you-go available to create a new seat",
99+
);
100+
expect((error as Error).message).toContain("Do not retry");
101+
});
102+
103+
it("does not treat other validation errors as seat errors", async () => {
104+
mswServer.use(
105+
http.put(
106+
"https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/uptime/4509100000001001/",
107+
() => HttpResponse.json({ url: ["Invalid URL"] }, { status: 400 }),
108+
),
109+
);
110+
111+
const error = await updateUptimeMonitor
112+
.handler(
113+
{
114+
organizationSlug: "sentry-mcp-evals",
115+
regionUrl: null,
116+
projectSlug: "cloudflare-mcp",
117+
uptimeMonitorId: "4509100000001001",
118+
status: "active",
119+
},
120+
context,
121+
)
122+
.catch((e: unknown) => e);
123+
124+
expect(error).toBeInstanceOf(ApiValidationError);
125+
});
126+
65127
it("clears owner when null is provided", async () => {
66128
let requestBody: Record<string, unknown> | null = null;
67129
mswServer.use(

‎packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { ApiValidationError } from "../../api-client/index";
23
import { UserInputError } from "../../errors";
34
import { apiServiceFromContext } from "../../internal/tool-helpers/api";
45
import { defineTool } from "../../internal/tool-helpers/define";
@@ -27,6 +28,21 @@ export const updateUptimeMonitorOutputSchema = z.object({
2728
monitor: uptimeMonitorSummarySchema,
2829
});
2930

31+
const seatErrorSchema = z.object({ status: z.array(z.string()).min(1) });
32+
33+
// The API client only keeps a field error's body as raw text in `detail`.
34+
function getSeatErrorReason(error: unknown): string | undefined {
35+
if (!(error instanceof ApiValidationError) || !error.detail) {
36+
return undefined;
37+
}
38+
try {
39+
const parsed = seatErrorSchema.safeParse(JSON.parse(error.detail));
40+
return parsed.success ? parsed.data.status[0] : undefined;
41+
} catch {
42+
return undefined;
43+
}
44+
}
45+
3046
function hasDefinedUpdate(
3147
value: unknown,
3248
): value is Exclude<typeof value, undefined> {
@@ -138,25 +154,36 @@ export default defineTool({
138154
);
139155
}
140156

141-
const monitor = await apiService.updateUptimeMonitor({
142-
organizationSlug,
143-
projectSlug: params.projectSlug,
144-
uptimeMonitorId: params.uptimeMonitorId,
145-
name: params.name,
146-
url: params.url,
147-
intervalSeconds: params.intervalSeconds,
148-
timeoutMs: params.timeoutMs,
149-
method: params.method,
150-
headers: params.headers && toUptimeHeaderPairs(params.headers),
151-
body: params.body,
152-
status: params.status,
153-
owner: params.owner,
154-
environment: params.environment,
155-
traceSampling: params.traceSampling,
156-
responseCaptureEnabled: params.responseCaptureEnabled,
157-
recoveryThreshold: params.recoveryThreshold,
158-
downtimeThreshold: params.downtimeThreshold,
159-
});
157+
const monitor = await apiService
158+
.updateUptimeMonitor({
159+
organizationSlug,
160+
projectSlug: params.projectSlug,
161+
uptimeMonitorId: params.uptimeMonitorId,
162+
name: params.name,
163+
url: params.url,
164+
intervalSeconds: params.intervalSeconds,
165+
timeoutMs: params.timeoutMs,
166+
method: params.method,
167+
headers: params.headers && toUptimeHeaderPairs(params.headers),
168+
body: params.body,
169+
status: params.status,
170+
owner: params.owner,
171+
environment: params.environment,
172+
traceSampling: params.traceSampling,
173+
responseCaptureEnabled: params.responseCaptureEnabled,
174+
recoveryThreshold: params.recoveryThreshold,
175+
downtimeThreshold: params.downtimeThreshold,
176+
})
177+
.catch((error: unknown) => {
178+
const reason = params.status === "active" && getSeatErrorReason(error);
179+
if (reason) {
180+
throw new UserInputError(
181+
`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.`,
182+
{ cause: error },
183+
);
184+
}
185+
throw error;
186+
});
160187

161188
return structuredResult({
162189
monitor: toUptimeMonitorSummary(

0 commit comments

Comments
 (0)