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
62 changes: 62 additions & 0 deletions packages/mcp-core/src/tools/catalog/update-uptime-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, unknown> | null = null;
mswServer.use(
Expand Down
65 changes: 46 additions & 19 deletions packages/mcp-core/src/tools/catalog/update-uptime-monitor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof value, undefined> {
Expand Down Expand Up @@ -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(
Expand Down
Loading