From e6286f5ec3fcf8f68adb2f2690c38ed5992665a6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:54:07 +0000 Subject: [PATCH 1/3] feat(tools): Add outputSchema to get_monitor_details Return cron monitor details as structuredContent with a declared outputSchema instead of handwritten markdown. Config, environments, check-ins, and stat buckets are mapped explicitly, so passthrough fields such as alertRule and extra config keys stay private. Durations are labeled in milliseconds (Sentry's unit); the old markdown printed them as seconds. The search_issues follow-up hint moves into the tool description. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 202 ++++++++++++++- .../tools/catalog/get-monitor-details.test.ts | 211 +++++++++++----- .../src/tools/catalog/get-monitor-details.ts | 238 +++++++++++------- .../tools/catalog/get-sentry-resource.test.ts | 38 +-- .../src/fixtures/monitor-checkins.json | 6 +- .../src/fixtures/monitor-stats.json | 2 +- .../src/fixtures/monitor.json | 2 +- 8 files changed, 523 insertions(+), 178 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 9c410cac2..5f458cab8 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -129,7 +129,7 @@ }, { "name": "get_monitor_details", - "description": "Get details and recent check-ins for a Sentry cron monitor (scheduled job).\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', period='7d')\n", + "description": "Get details and recent check-ins for a Sentry cron monitor (scheduled job).\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\nTo find issues from a monitor, call `search_issues` with query `monitor.slug:`.\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', period='7d')\n", "requiredScopes": ["project:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index e2598f4ca..642173212 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5482,7 +5482,7 @@ { "name": "get_monitor_details", "title": "Get Cron Monitor Details", - "description": "Get details and recent check-ins for a Sentry cron monitor (scheduled job).\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', period='7d')\n", + "description": "Get details and recent check-ins for a Sentry cron monitor (scheduled job).\n\nUse this tool when you need to:\n- Inspect a monitor's schedule, status, owner, project, and environments\n- Review recent check-ins for missed, failed, timeout, or OK runs\n- Check monitor stats over a recent time range\n\nTo find issues from a monitor, call `search_issues` with query `monitor.slug:`.\n\n\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')\nget_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', period='7d')\n", "inputSchema": { "type": "object", "properties": { @@ -5598,6 +5598,206 @@ }, "required": ["organizationSlug", "monitorSlug"] }, + "outputSchema": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + }, + "project": { + "type": ["string", "null"] + }, + "status": { + "type": ["string", "null"] + }, + "type": { + "type": ["string", "null"] + }, + "owner": { + "type": ["string", "null"] + }, + "lastCheckIn": { + "type": ["string", "null"] + }, + "nextCheckIn": { + "type": ["string", "null"] + }, + "url": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "type": "object", + "properties": { + "scheduleType": { + "type": ["string", "null"] + }, + "schedule": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": ["number", "string"] + } + } + ] + }, + { + "type": "null" + } + ], + "description": "Crontab string, or [value, unit] for interval schedules." + }, + "timezone": { + "type": ["string", "null"] + }, + "checkinMarginMinutes": { + "type": ["number", "null"] + }, + "maxRuntimeMinutes": { + "type": ["number", "null"] + } + }, + "required": [ + "scheduleType", + "schedule", + "timezone", + "checkinMarginMinutes", + "maxRuntimeMinutes" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "slug", + "name", + "project", + "status", + "type", + "owner", + "lastCheckIn", + "nextCheckIn", + "url", + "config" + ], + "additionalProperties": false + }, + "environments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": ["string", "null"] + }, + "status": { + "type": ["string", "null"] + }, + "lastCheckIn": { + "type": ["string", "null"] + }, + "nextCheckIn": { + "type": ["string", "null"] + } + }, + "required": ["name", "status", "lastCheckIn", "nextCheckIn"], + "additionalProperties": false + } + }, + "checkIns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": ["string", "null"] + }, + "status": { + "type": ["string", "null"] + }, + "durationMs": { + "type": ["number", "null"] + }, + "environment": { + "type": ["string", "null"] + } + }, + "required": ["date", "status", "durationMs", "environment"], + "additionalProperties": false + } + }, + "stats": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string" + }, + "ok": { + "type": ["number", "null"] + }, + "error": { + "type": ["number", "null"] + }, + "missed": { + "type": ["number", "null"] + }, + "timeout": { + "type": ["number", "null"] + }, + "unknown": { + "type": ["number", "null"] + }, + "avgDurationMs": { + "type": ["number", "null"] + } + }, + "required": [ + "timestamp", + "ok", + "error", + "missed", + "timeout", + "unknown", + "avgDurationMs" + ], + "additionalProperties": false + } + }, + { + "type": "null" + } + ], + "description": "At most 10 most recent stat buckets, oldest first. Null when includeStats is false." + } + }, + "required": ["monitor", "environments", "checkIns", "stats"], + "additionalProperties": false + }, "requiredScopes": ["project:read"], "skills": ["inspect"], "surface": "direct" diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 3ae59031f..21278610b 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -1,7 +1,13 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; -import getMonitorDetails from "./get-monitor-details.js"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content"; +import getMonitorDetails, { + getMonitorDetailsOutputSchema, +} from "./get-monitor-details.js"; import { prepareToolParams } from "../catalog-runtime/availability"; const context = { @@ -12,6 +18,28 @@ const context = { userId: "1", }; +const baseParams = { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + projectSlugOrId: null, + monitorSlug: "nightly-import", + environment: null, + period: "24h", + start: null, + end: null, + checkInLimit: 10, + includeStats: true, + rollupSeconds: null, +}; + +function getOutput(result: unknown) { + assertStructuredOnlyResult(result); + const content = getStructuredContent(result); + const parsed = getMonitorDetailsOutputSchema.parse(content); + expect(parsed).toEqual(content); + return parsed; +} + describe("get_monitor_details", () => { it("serializes monitor details", async () => { const result = await getMonitorDetails.handler( @@ -31,52 +59,122 @@ describe("get_monitor_details", () => { context, ); - expect(result).toMatchInlineSnapshot(` - "# Monitor Nightly Import in **sentry-mcp-evals** - - **Slug**: nightly-import - **ID**: 4509100000000001 - **Project**: cloudflare-mcp - **Status**: ok - **Owner**: the-goats - **Last Check-In**: 2025-04-14T02:00:13.000Z - **Next Check-In**: 2025-04-15T02:00:00.000Z - **URL**: [Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/) - - ## Schedule - - - **schedule**: ["crontab","0 2 * * *"] - - **schedule_type**: crontab - - **timezone**: UTC - - **checkin_margin**: 5 - - **max_runtime**: 30 - - ## Environments - - - production - - Status: ok - - Last check-in: 2025-04-14T02:00:13.000Z - - Next check-in: 2025-04-15T02:00:00.000Z - - staging - - Status: missed_checkin - - Last check-in: 2025-04-13T02:00:18.000Z - - Next check-in: 2025-04-14T02:00:00.000Z - - ## Recent Check-Ins - - - 2025-04-14T02:00:13.000Z: ok, 13.2s, production - - 2025-04-14T02:05:00.000Z: missed, staging - - ## Stats - - - 2025-04-14T02:00:00.000Z: ok=1, error=0, missed=0, timeout=0, unknown=0, duration=13.2 - - 2025-04-15T02:00:00.000Z: ok=0, error=0, missed=1, timeout=0, unknown=0, duration=0 + expect(getOutput(result)).toMatchInlineSnapshot(` + { + "checkIns": [ + { + "date": "2025-04-14T02:00:13.000Z", + "durationMs": 13200, + "environment": "production", + "status": "ok", + }, + { + "date": "2025-04-14T02:05:00.000Z", + "durationMs": null, + "environment": "staging", + "status": "missed", + }, + ], + "environments": [ + { + "lastCheckIn": "2025-04-14T02:00:13.000Z", + "name": "production", + "nextCheckIn": "2025-04-15T02:00:00.000Z", + "status": "ok", + }, + { + "lastCheckIn": "2025-04-13T02:00:18.000Z", + "name": "staging", + "nextCheckIn": "2025-04-14T02:00:00.000Z", + "status": "missed_checkin", + }, + ], + "monitor": { + "config": { + "checkinMarginMinutes": 5, + "maxRuntimeMinutes": 30, + "schedule": "0 2 * * *", + "scheduleType": "crontab", + "timezone": "UTC", + }, + "id": "4509100000000001", + "lastCheckIn": "2025-04-14T02:00:13.000Z", + "name": "Nightly Import", + "nextCheckIn": "2025-04-15T02:00:00.000Z", + "owner": "the-goats", + "project": "cloudflare-mcp", + "slug": "nightly-import", + "status": "ok", + "type": null, + "url": "https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly-import/", + }, + "stats": [ + { + "avgDurationMs": 13200, + "error": 0, + "missed": 0, + "ok": 1, + "timeout": 0, + "timestamp": "2025-04-14T02:00:00.000Z", + "unknown": 0, + }, + { + "avgDurationMs": 0, + "error": 0, + "missed": 1, + "ok": 0, + "timeout": 0, + "timestamp": "2025-04-15T02:00:00.000Z", + "unknown": 0, + }, + ], + } + `); + }); - ## Response Notes + it("does not leak backend-only monitor fields", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + () => + HttpResponse.json({ + id: "4509100000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "ok", + isUpserting: false, + project: { id: "4509109104082945", slug: "cloudflare-mcp" }, + environments: [], + owner: { + type: "user", + id: "1", + name: "Jane Developer", + email: "jane@example.com", + }, + alertRule: { targets: [{ targetIdentifier: "secret-target" }] }, + config: { + schedule_type: "crontab", + schedule: "0 2 * * *", + failure_issue_threshold: 3, + alert_rule_id: 987654, + }, + }), + ), + ); - - Search issues from this monitor with \`search_issues\` query \`monitor.slug:nightly-import\`. - " - `); + const result = await getMonitorDetails.handler(baseParams, context); + + const text = JSON.stringify(getOutput(result)); + for (const leaked of [ + "jane@example.com", + "secret-target", + "alertRule", + "failure_issue_threshold", + "987654", + "isUpserting", + ]) { + expect(text).not.toContain(leaked); + } }); it("rejects monitors outside the active project constraint", async () => { @@ -123,7 +221,7 @@ describe("get_monitor_details", () => { }, config: { schedule_type: "crontab", - schedule: ["crontab", "0 2 * * *"], + schedule: "0 2 * * *", }, environments: [], }; @@ -174,9 +272,7 @@ describe("get_monitor_details", () => { }, ); - expect(result).toContain( - "# Monitor Nightly Import in **sentry-mcp-evals**", - ); + expect(getOutput(result).monitor.name).toBe("Nightly Import"); expect(paths).toEqual([ "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/", "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/monitors/nightly-import/checkins/", @@ -199,7 +295,7 @@ describe("get_monitor_details", () => { }, config: { schedule_type: "crontab", - schedule: ["crontab", "0 2 * * *"], + schedule: "0 2 * * *", }, environments: [], }; @@ -240,7 +336,7 @@ describe("get_monitor_details", () => { const result = await getMonitorDetails.handler(params, context); - expect(result).toContain("# Monitor Nightly Import in **MyOrg**"); + expect(getOutput(result).monitor.name).toBe("Nightly Import"); expect(paths).toEqual([ "/api/0/projects/MyOrg/MyProject/monitors/nightly-import/", "/api/0/projects/MyOrg/MyProject/monitors/nightly-import/checkins/", @@ -263,7 +359,7 @@ describe("get_monitor_details", () => { }, config: { schedule_type: "crontab", - schedule: ["crontab", "0 2 * * *"], + schedule: "0 2 * * *", }, environments: [], }; @@ -379,7 +475,7 @@ describe("get_monitor_details", () => { }, config: { schedule_type: "crontab", - schedule: ["crontab", "0 2 * * *"], + schedule: "0 2 * * *", }, environments: [], }; @@ -446,7 +542,7 @@ describe("get_monitor_details", () => { }, config: { schedule_type: "crontab", - schedule: ["crontab", "0 2 * * *"], + schedule: "0 2 * * *", }, environments: [], }; @@ -486,10 +582,11 @@ describe("get_monitor_details", () => { context, ); - expect(result).toContain( - "[Open Monitor](https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/)", + const output = getOutput(result); + expect(output.monitor.url).toBe( + "https://sentry-mcp-evals.sentry.io/crons/cloudflare-mcp/nightly%2Fimport%201/", ); - expect(result).not.toContain("## Stats"); + expect(output.stats).toBeNull(); }); describe("tool definition", () => { diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index 54168df80..73e3062f3 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -7,6 +7,7 @@ import type { import { UserInputError } from "../../errors"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; import { ParamOrganizationSlug, ParamPeriod, @@ -16,14 +17,76 @@ import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; import { isNumericId, validateSlugOrId } from "../../utils/slug-validation"; import { - compactLines, formatActor, formatDate, formatId, - formatUnknown, + readString, } from "./support/api-formatting"; import { assertProjectRefWithinConstraint } from "./support/project-constraints"; +const STATS_BUCKET_LIMIT = 10; +const nullableString = z.string().nullable(); +const nullableNumber = z.number().nullable(); + +const monitorConfigSchema = z.object({ + scheduleType: nullableString, + schedule: z + .union([z.string(), z.array(z.union([z.number(), z.string()]))]) + .nullable() + .describe("Crontab string, or [value, unit] for interval schedules."), + timezone: nullableString, + checkinMarginMinutes: nullableNumber, + maxRuntimeMinutes: nullableNumber, +}); + +const checkInSchema = z.object({ + date: nullableString, + status: nullableString, + durationMs: nullableNumber, + environment: nullableString, +}); + +const statBucketSchema = z.object({ + timestamp: z.string(), + ok: nullableNumber, + error: nullableNumber, + missed: nullableNumber, + timeout: nullableNumber, + unknown: nullableNumber, + avgDurationMs: nullableNumber, +}); + +export const getMonitorDetailsOutputSchema = z.object({ + monitor: z.object({ + id: z.string(), + slug: z.string(), + name: nullableString, + project: nullableString, + status: nullableString, + type: nullableString, + owner: nullableString, + lastCheckIn: nullableString, + nextCheckIn: nullableString, + url: z.string(), + config: monitorConfigSchema.nullable(), + }), + environments: z.array( + z.object({ + name: nullableString, + status: nullableString, + lastCheckIn: nullableString, + nextCheckIn: nullableString, + }), + ), + checkIns: z.array(checkInSchema), + stats: z + .array(statBucketSchema) + .nullable() + .describe( + `At most ${STATS_BUCKET_LIMIT} most recent stat buckets, oldest first. Null when includeStats is false.`, + ), +}); + function formatProject(monitor: Monitor): string | null { if (!monitor.project) { return null; @@ -32,43 +95,64 @@ function formatProject(monitor: Monitor): string | null { return monitor.project.slug ?? monitor.project.name ?? null; } -function formatConfig(monitor: Monitor): string[] { +function readNumber( + value: Record, + key: string, +): number | null { + const field = value[key]; + return typeof field === "number" ? field : null; +} + +function readSchedule( + value: unknown, +): z.infer["schedule"] { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.filter( + (item): item is string | number => + typeof item === "string" || typeof item === "number", + ); + } + return null; +} + +function toConfig( + monitor: Monitor, +): z.infer | null { const config = monitor.config; if (!config) { - return []; + return null; } - - return [ - "schedule", - "schedule_type", - "timezone", - "checkin_margin", - "max_runtime", - ] - .filter((key) => config[key] !== undefined) - .map((key) => `- **${key}**: ${formatUnknown(config[key])}`); + return { + scheduleType: readString(config, "schedule_type"), + schedule: readSchedule(config.schedule), + timezone: readString(config, "timezone"), + checkinMarginMinutes: readNumber(config, "checkin_margin"), + maxRuntimeMinutes: readNumber(config, "max_runtime"), + }; } -function formatCheckIn(checkIn: MonitorCheckIn): string { - const date = - formatDate(checkIn.dateCreated) ?? - formatDate(checkIn.dateUpdated) ?? - "unknown time"; - const duration = - checkIn.duration === undefined || checkIn.duration === null - ? "" - : `, ${checkIn.duration}s`; - const environment = checkIn.environment ? `, ${checkIn.environment}` : ""; - return `- ${date}: ${checkIn.status ?? "unknown"}${duration}${environment}`; +function toCheckIn(checkIn: MonitorCheckIn): z.infer { + return { + date: formatDate(checkIn.dateCreated) ?? formatDate(checkIn.dateUpdated), + status: checkIn.status ?? null, + durationMs: checkIn.duration ?? null, + environment: checkIn.environment ?? null, + }; } -function formatStat(stat: MonitorStat): string { - const timestamp = - formatDate(new Date(stat.ts * 1000).toISOString()) ?? String(stat.ts); - const parts = Object.entries(stat) - .filter(([key]) => key !== "ts") - .map(([key, value]) => `${key}=${formatUnknown(value)}`); - return `- ${timestamp}: ${parts.join(", ") || "no check-ins"}`; +function toStat(stat: MonitorStat): z.infer { + return { + timestamp: new Date(stat.ts * 1000).toISOString(), + ok: readNumber(stat, "ok"), + error: readNumber(stat, "error"), + missed: readNumber(stat, "missed"), + timeout: readNumber(stat, "timeout"), + unknown: readNumber(stat, "unknown"), + avgDurationMs: readNumber(stat, "duration"), + }; } export default defineTool({ @@ -84,6 +168,8 @@ export default defineTool({ "- Review recent check-ins for missed, failed, timeout, or OK runs", "- Check monitor stats over a recent time range", "", + "To find issues from a monitor, call `search_issues` with query `monitor.slug:`.", + "", "", "get_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import')", "get_monitor_details(organizationSlug='my-organization', monitorSlug='nightly-import', projectSlugOrId='backend', environment='production', period='7d')", @@ -152,6 +238,7 @@ export default defineTool({ .nullable() .default(null), }, + outputSchema: getMonitorDetailsOutputSchema, annotations: { readOnlyHint: true, destructiveHint: false, @@ -252,69 +339,30 @@ export default defineTool({ : Promise.resolve([]), ]); - const owner = monitor.owner ? formatActor(monitor.owner) : null; - const output = compactLines([ - `# Monitor ${monitor.name ?? monitor.slug} in **${organizationSlug}**`, - "", - `**Slug**: ${monitor.slug}`, - `**ID**: ${formatId(monitor.id)}`, - project ? `**Project**: ${project}` : null, - monitor.status ? `**Status**: ${monitor.status}` : null, - monitor.type ? `**Type**: ${monitor.type}` : null, - owner ? `**Owner**: ${owner}` : null, - formatDate(monitor.lastCheckIn) - ? `**Last Check-In**: ${formatDate(monitor.lastCheckIn)}` - : null, - formatDate(monitor.nextCheckIn) - ? `**Next Check-In**: ${formatDate(monitor.nextCheckIn)}` + return structuredResult({ + monitor: { + id: formatId(monitor.id), + slug: monitor.slug, + name: monitor.name ?? null, + project, + status: monitor.status ?? null, + type: monitor.type ?? null, + owner: monitor.owner ? formatActor(monitor.owner) : null, + lastCheckIn: formatDate(monitor.lastCheckIn), + nextCheckIn: formatDate(monitor.nextCheckIn), + url: monitorUrl, + config: toConfig(monitor), + }, + environments: (monitor.environments ?? []).map((environment) => ({ + name: environment.name ?? null, + status: environment.status ?? null, + lastCheckIn: formatDate(environment.lastCheckIn), + nextCheckIn: formatDate(environment.nextCheckIn), + })), + checkIns: checkIns.slice(0, params.checkInLimit).map(toCheckIn), + stats: params.includeStats + ? stats.slice(-STATS_BUCKET_LIMIT).map(toStat) : null, - `**URL**: [Open Monitor](${monitorUrl})`, - ]); - - const config = formatConfig(monitor); - if (config.length > 0) { - output.push("", "## Schedule", "", ...config); - } - - if (monitor.environments && monitor.environments.length > 0) { - output.push("", "## Environments", ""); - for (const environment of monitor.environments) { - output.push( - compactLines([ - `- ${environment.name ?? "unknown"}`, - environment.status ? ` - Status: ${environment.status}` : null, - formatDate(environment.lastCheckIn) - ? ` - Last check-in: ${formatDate(environment.lastCheckIn)}` - : null, - formatDate(environment.nextCheckIn) - ? ` - Next check-in: ${formatDate(environment.nextCheckIn)}` - : null, - ]).join("\n"), - ); - } - } - - output.push("", "## Recent Check-Ins", ""); - output.push( - checkIns.length === 0 - ? "No check-ins found in this time range." - : checkIns.slice(0, params.checkInLimit).map(formatCheckIn).join("\n"), - ); - - if (params.includeStats) { - output.push("", "## Stats", ""); - output.push( - stats.length === 0 - ? "No stats found in this time range." - : stats.slice(-10).map(formatStat).join("\n"), - ); - } - - output.push("", "## Response Notes", ""); - output.push( - `- Search issues from this monitor with \`search_issues\` query \`monitor.slug:${monitor.slug}\`.`, - ); - - return `${output.join("\n")}\n`; + }); }, }); diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts index 8778be9bb..f5523eb0f 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts @@ -403,10 +403,10 @@ describe("get_sentry_resource", () => { const result = await callHandler({ url: "https://my-org.sentry.io/crons/daily-backup/", }); - expect(result).toContain("# Monitor daily-backup in **my-org**"); - expect(result).toContain( - "[Open Monitor](https://my-org.sentry.io/crons/backend/daily-backup/)", - ); + expect(getStructuredContent(result).monitor).toMatchObject({ + slug: "daily-backup", + url: "https://my-org.sentry.io/crons/backend/daily-backup/", + }); }); it("dispatches monitor URL with project/slug path to get_monitor_details", async () => { @@ -421,10 +421,10 @@ describe("get_sentry_resource", () => { const result = await callHandler({ url: "https://my-org.sentry.io/crons/my-project/my-monitor/", }); - expect(result).toContain("# Monitor my-monitor in **my-org**"); - expect(result).toContain( - "[Open Monitor](https://my-org.sentry.io/crons/my-project/my-monitor/)", - ); + expect(getStructuredContent(result).monitor).toMatchObject({ + slug: "my-monitor", + url: "https://my-org.sentry.io/crons/my-project/my-monitor/", + }); }); it("dispatches monitor URL with numeric project ID under an active project constraint", async () => { @@ -450,10 +450,10 @@ describe("get_sentry_resource", () => { }, ); - expect(result).toContain("# Monitor my-monitor in **my-org**"); - expect(result).toContain( - "[Open Monitor](https://my-org.sentry.io/crons/backend/my-monitor/)", - ); + expect(getStructuredContent(result).monitor).toMatchObject({ + slug: "my-monitor", + url: "https://my-org.sentry.io/crons/backend/my-monitor/", + }); }); it("rejects numeric monitor project IDs outside the active project constraint before monitor reads", async () => { @@ -530,10 +530,10 @@ describe("get_sentry_resource", () => { }, ); - expect(result).toContain("# Monitor my-monitor in **my-org**"); - expect(result).toContain( - "[Open Monitor](https://my-org.sentry.io/crons/backend/my-monitor/)", - ); + expect(getStructuredContent(result).monitor).toMatchObject({ + slug: "my-monitor", + url: "https://my-org.sentry.io/crons/backend/my-monitor/", + }); }); it("returns guidance for release URL", async () => { @@ -594,9 +594,9 @@ describe("get_sentry_resource", () => { sentryProtocol: "http", }, ); - expect(result).toContain( - "[Open Monitor](http://sentry.internal:9000/organizations/my-org/crons/backend/daily-backup/)", - ); + expect(getStructuredContent(result).monitor).toMatchObject({ + url: "http://sentry.internal:9000/organizations/my-org/crons/backend/daily-backup/", + }); }); it("uses configured host and protocol for self-hosted release URL", async () => { diff --git a/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json b/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json index ef63f1bb4..9ef82baba 100644 --- a/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json +++ b/packages/mcp-server-mocks/src/fixtures/monitor-checkins.json @@ -3,7 +3,7 @@ "id": "11111111-1111-4111-8111-111111111111", "environment": "production", "status": "ok", - "duration": 13.2, + "duration": 13200, "dateCreated": "2025-04-14T02:00:13.000Z", "dateAdded": "2025-04-14T02:00:13.000Z", "dateUpdated": "2025-04-14T02:00:26.000Z", @@ -11,7 +11,7 @@ "dateClock": "2025-04-14T02:00:00.000Z", "expectedTime": "2025-04-14T02:00:00.000Z", "monitorConfig": { - "schedule": ["crontab", "0 2 * * *"], + "schedule": "0 2 * * *", "schedule_type": "crontab" } }, @@ -27,7 +27,7 @@ "dateClock": "2025-04-14T02:00:00.000Z", "expectedTime": "2025-04-14T02:00:00.000Z", "monitorConfig": { - "schedule": ["crontab", "0 2 * * *"], + "schedule": "0 2 * * *", "schedule_type": "crontab" } } diff --git a/packages/mcp-server-mocks/src/fixtures/monitor-stats.json b/packages/mcp-server-mocks/src/fixtures/monitor-stats.json index b87fe867e..f89e4b4b1 100644 --- a/packages/mcp-server-mocks/src/fixtures/monitor-stats.json +++ b/packages/mcp-server-mocks/src/fixtures/monitor-stats.json @@ -6,7 +6,7 @@ "missed": 0, "timeout": 0, "unknown": 0, - "duration": 13.2 + "duration": 13200 }, { "ts": 1744682400, diff --git a/packages/mcp-server-mocks/src/fixtures/monitor.json b/packages/mcp-server-mocks/src/fixtures/monitor.json index 4adc39e2e..9c2cd99dd 100644 --- a/packages/mcp-server-mocks/src/fixtures/monitor.json +++ b/packages/mcp-server-mocks/src/fixtures/monitor.json @@ -19,7 +19,7 @@ "lastCheckIn": "2025-04-14T02:00:13.000Z", "nextCheckIn": "2025-04-15T02:00:00.000Z", "config": { - "schedule": ["crontab", "0 2 * * *"], + "schedule": "0 2 * * *", "schedule_type": "crontab", "timezone": "UTC", "checkin_margin": 5, From 06e9bb97c2978b6dd3acc8fb130a08b35cfb33e3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:07:30 +0000 Subject: [PATCH 2/3] feat(tools): Return the monitor owner's email Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- packages/mcp-core/src/toolDefinitions.json | 5 +++++ .../mcp-core/src/tools/catalog/get-monitor-details.test.ts | 7 +++++-- packages/mcp-core/src/tools/catalog/get-monitor-details.ts | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 642173212..f8af26a7e 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5625,6 +5625,10 @@ "owner": { "type": ["string", "null"] }, + "ownerEmail": { + "description": "Set when a user owns the monitor.", + "type": ["string", "null"] + }, "lastCheckIn": { "type": ["string", "null"] }, @@ -5696,6 +5700,7 @@ "status", "type", "owner", + "ownerEmail", "lastCheckIn", "nextCheckIn", "url", diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 21278610b..67abb1b44 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -102,6 +102,7 @@ describe("get_monitor_details", () => { "name": "Nightly Import", "nextCheckIn": "2025-04-15T02:00:00.000Z", "owner": "the-goats", + "ownerEmail": null, "project": "cloudflare-mcp", "slug": "nightly-import", "status": "ok", @@ -164,9 +165,11 @@ describe("get_monitor_details", () => { const result = await getMonitorDetails.handler(baseParams, context); - const text = JSON.stringify(getOutput(result)); + const output = getOutput(result); + // The owner's email is user data Sentry stored, so it comes through. + expect(output.monitor.ownerEmail).toBe("jane@example.com"); + const text = JSON.stringify(output); for (const leaked of [ - "jane@example.com", "secret-target", "alertRule", "failure_issue_threshold", diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index 73e3062f3..05cdb163a 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -65,6 +65,7 @@ export const getMonitorDetailsOutputSchema = z.object({ status: nullableString, type: nullableString, owner: nullableString, + ownerEmail: nullableString.describe("Set when a user owns the monitor."), lastCheckIn: nullableString, nextCheckIn: nullableString, url: z.string(), @@ -348,6 +349,10 @@ export default defineTool({ status: monitor.status ?? null, type: monitor.type ?? null, owner: monitor.owner ? formatActor(monitor.owner) : null, + ownerEmail: + monitor.owner && typeof monitor.owner === "object" + ? monitor.owner.email || null + : null, lastCheckIn: formatDate(monitor.lastCheckIn), nextCheckIn: formatDate(monitor.nextCheckIn), url: monitorUrl, From 267dae3a9171eceebdc7efde8aa3fa8d2023b275 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:56:21 +0000 Subject: [PATCH 3/3] fix(tools): Return a null monitor owner instead of "unknown" Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../tools/catalog/get-monitor-details.test.ts | 26 +++++++++++++++++++ .../src/tools/catalog/get-monitor-details.ts | 7 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 67abb1b44..01ba89335 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -133,6 +133,32 @@ describe("get_monitor_details", () => { `); }); + it("returns a null owner when Sentry has no usable identity", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/monitors/nightly-import/", + () => + HttpResponse.json({ + id: "4509100000000001", + slug: "nightly-import", + name: "Nightly Import", + status: "ok", + project: { id: "4509109104082945", slug: "cloudflare-mcp" }, + environments: [], + owner: { type: "user" }, + config: { schedule_type: "crontab", schedule: "0 2 * * *" }, + }), + ), + ); + + const output = getOutput( + await getMonitorDetails.handler(baseParams, context), + ); + + expect(output.monitor.owner).toBeNull(); + expect(output.monitor.ownerEmail).toBeNull(); + }); + it("does not leak backend-only monitor fields", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts index 05cdb163a..0d53fed47 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.ts @@ -88,6 +88,11 @@ export const getMonitorDetailsOutputSchema = z.object({ ), }); +function formatOwner(owner: Monitor["owner"]): string | null { + const name = owner ? formatActor(owner) : null; + return name === "unknown" ? null : name; +} + function formatProject(monitor: Monitor): string | null { if (!monitor.project) { return null; @@ -348,7 +353,7 @@ export default defineTool({ project, status: monitor.status ?? null, type: monitor.type ?? null, - owner: monitor.owner ? formatActor(monitor.owner) : null, + owner: formatOwner(monitor.owner), ownerEmail: monitor.owner && typeof monitor.owner === "object" ? monitor.owner.email || null