Skip to content
Draft
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
67 changes: 49 additions & 18 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -3244,39 +3251,63 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<UptimeMonitor> {
const payload: Record<string, unknown> = {
name,
const dataSource: Record<string, unknown> = {
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<string, unknown> = {};
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<string, unknown> = {
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,
);
}

/**
Expand Down
138 changes: 98 additions & 40 deletions packages/mcp-core/src/tools/catalog/create-uptime-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
return HttpResponse.json(createdDetector, { status: 201 });
}),
http.get(monitorUrl, () => HttpResponse.json(savedMonitor)),
);

const result = await createUptimeMonitor.handler(
Expand All @@ -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);
Expand Down Expand Up @@ -108,6 +130,42 @@ describe("create_uptime_monitor", () => {
`);
});

it("disables the monitor after creation when status is disabled", async () => {
let createBody: Record<string, unknown> | null = null;
let updateBody: Record<string, unknown> | null = null;
mswServer.use(
http.post(detectorsUrl, async ({ request }) => {
createBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(createdDetector, { status: 201 });
}),
http.put(monitorUrl, async ({ request }) => {
updateBody = (await request.json()) as Record<string, unknown>;
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);
Expand Down
36 changes: 22 additions & 14 deletions packages/mcp-server-mocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>) },
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/",
Expand Down
Loading