diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index 1f469b5a19..690604617e 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -12,6 +12,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" @@ -273,6 +274,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AlertReadModelsService.layer, AlertIncidentAckService.layer, AlertRulesService.layer, + AlertSilencesService.layer, AlertsService.layer, AuditLogService.layer, DashboardPersistenceService.layer, diff --git a/apps/ai/src/mcp/lib/alert-silences.ts b/apps/ai/src/mcp/lib/alert-silences.ts new file mode 100644 index 0000000000..06a567cdd9 --- /dev/null +++ b/apps/ai/src/mcp/lib/alert-silences.ts @@ -0,0 +1,97 @@ +import { Array as Arr, Effect } from "effect" +import type { + AlertForbiddenError, + AlertPersistenceError, + AlertSilenceDocument, + AlertSilenceMatchers, + AlertSilenceNotFoundError, + AlertValidationError, +} from "@maple/domain/http" +import type { AlertSilenceRow } from "@maple/domain/mcp-outputs" +import { McpInvalidInputError } from "../tools/types" +import { toMcpHttpError } from "./map-http-error" +import { doc } from "./tool-doc" + +export const ALERT_SILENCE_STATUSES = ["scheduled", "active", "ended"] as const + +type SilenceRow = typeof AlertSilenceRow.Type + +export const toAlertSilenceRow = (silence: AlertSilenceDocument): SilenceRow => ({ + id: silence.id, + reason: silence.reason, + matchers: { + ...(silence.matchers.ruleIds === undefined ? undefined : { ruleIds: [...silence.matchers.ruleIds] }), + ...(silence.matchers.tags === undefined ? undefined : { tags: [...silence.matchers.tags] }), + ...(silence.matchers.services === undefined + ? undefined + : { services: [...silence.matchers.services] }), + ...(silence.matchers.environments === undefined + ? undefined + : { environments: [...silence.matchers.environments] }), + }, + status: silence.status, + startsAt: silence.startsAt, + endsAt: silence.endsAt, + createdBy: silence.createdBy, +}) + +/** The matchers as one line, e.g. `services: checkout AND environments: production`. */ +export const formatSilenceMatchers = (matchers: SilenceRow["matchers"] | AlertSilenceMatchers): string => { + const entries: ReadonlyArray | undefined]> = [ + ["rules", matchers.ruleIds], + ["tags", matchers.tags], + ["services", matchers.services], + ["environments", matchers.environments], + ] + const parts = Arr.flatMap(entries, ([label, values]) => + values === undefined || values.length === 0 ? [] : [`${label}: ${Arr.join(values, ", ")}`], + ) + return parts.length === 0 ? "-" : Arr.join(parts, " AND ") +} + +export const silenceFields = (silence: SilenceRow) => + doc.fields([ + ["ID", silence.id], + ["Reason", silence.reason], + ["Status", silence.status], + ["Matches", formatSilenceMatchers(silence.matchers)], + ["Starts", silence.startsAt], + ["Ends", silence.endsAt], + ["Created by", silence.createdBy], + ]) + +export const silenceNotFound = (silenceId: string) => + new McpInvalidInputError({ + message: `Alert silence not found: ${silenceId}. Use list_alert_silences to find silence IDs.`, + parameter: "silence_id", + }) + +const failWith = (pipeName: string) => (error: AlertForbiddenError | AlertPersistenceError) => + Effect.fail(toMcpHttpError(pipeName)(error)) + +/** create_alert_silence failures: an invalid window or matcher is the caller's to fix. */ +export const createSilenceErrors = { + // The details name what to fix (which rule ids do not exist), so they ride along. + "@maple/http/errors/AlertValidationError": (error: AlertValidationError) => + Effect.fail( + new McpInvalidInputError({ + message: Arr.join( + [ + `Invalid alert silence: ${error.message}`, + ...Arr.filter(error.details, (detail) => detail !== error.message), + ], + ". ", + ), + }), + ), + "@maple/http/errors/AlertForbiddenError": failWith("create_alert_silence"), + "@maple/http/errors/AlertPersistenceError": failWith("create_alert_silence"), +} + +/** end_alert_silence failures: an unknown id is invalid input on silence_id. */ +export const endSilenceErrors = { + "@maple/http/errors/AlertSilenceNotFoundError": (error: AlertSilenceNotFoundError) => + Effect.fail(silenceNotFound(error.silenceId)), + "@maple/http/errors/AlertForbiddenError": failWith("end_alert_silence"), + "@maple/http/errors/AlertPersistenceError": failWith("end_alert_silence"), +} diff --git a/apps/ai/src/mcp/lib/params.ts b/apps/ai/src/mcp/lib/params.ts index 2ee14fe336..f9f7481a15 100644 --- a/apps/ai/src/mcp/lib/params.ts +++ b/apps/ai/src/mcp/lib/params.ts @@ -22,6 +22,7 @@ import { AlertDestinationId, AlertIncidentId, AlertRuleId, + AlertSilenceId, DashboardId, ErrorIssueId, } from "@maple/domain/primitives" @@ -157,6 +158,7 @@ const RESOURCE_IDS = { schema: AlertDestinationId, noun: "alert destination", }, + alertSilence: { prefix: PublicIdPrefixes.alertSilence, schema: AlertSilenceId, noun: "alert silence" }, dashboard: { prefix: PublicIdPrefixes.dashboard, schema: DashboardId, noun: "dashboard" }, errorIssue: { prefix: PublicIdPrefixes.errorIssue, schema: ErrorIssueId, noun: "error issue" }, } as const diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 70a9ec197b..e788ce1be1 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -117,6 +117,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - Infrastructure (hosts, Kubernetes pods/nodes/workloads, Docker containers): list_infra (no \`kind\` = overview of what reports, hottest first) -> inspect_infra (CPU/memory over the window with the peak time, a node's or workload's pods). diagnose_service shows the workload and pods a service runs on. Connected integrations add kinds: cloudflare_zones / cloudflare_workers (requests, 5xx, latency; inspect a zone with section=traffic|breakdown|security|dns), railway_services and planetscale_databases; list_integrations says which are connected and healthy. Resource metrics are also chartable: create_dashboard group_by \`resource.host.name\` / \`resource.k8s.pod.name\` on source=metrics - Deploy verification: service_deployments (versions per service, newest vs previous) -> compare_periods around the rollout. Route usage over up to 90 days: route_usage. Silence check before calling an outage: ingest_freshness. Volume per service: ingest_usage. Database query shapes by volume: db_query_volume - Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> preview_alert_rule (dry-run before saving; a window with no data is a blind rule, not a healthy one) -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents +- Quieting alerts for a window (maintenance, a known outage): create_alert_silence (matchers + duration_minutes or ends_at) / list_alert_silences / end_alert_silence. Incidents still open; only notifications are held back. - Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor. - SLOs: list_slos is every SLO with its SLI, budget left and worst burn rate, burning ones first -> inspect_slo (each 5m/30m/1h/6h burn window, which tier breaches, alerting) -> get_incident_timeline rule_id=. Admins and engineers manage them with create_slo / update_slo (alerting_enabled=false silences it) / delete_slo. An SLO's burn-rate rule (signal type \`slo_burn_rate\`) is managed only through the SLO. - Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write. diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 1da2b6d3b6..d646e746a2 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -33,10 +33,13 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_alert_incidents","title":"List Alert Incidents","description":"Alert incidents: alert-rule breaches, open and resolved, with severity, group, condition and last observed value (flare-ups of an error issue are list_error_incidents). For one rule's trigger, notify and resolve timestamps use get_incident_timeline.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"acknowledged":{"type":"boolean","description":"true: open incidents someone acknowledged; false: open incidents nobody has. Implies status=open"},"limit":{"type":"number","description":"Max incidents to return (default 50, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"holdReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"heldSince":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"firstTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","holdReason","heldSince","threshold","comparator","firstTriggeredAt","resolvedAt","lastObservedValue"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"recentlyResolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"acknowledged":{"type":"boolean"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"List Alert Incidents","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_alert_checks","title":"List Alert Checks","description":"List a rule's recent checks (one row per evaluation) with observed value, threshold, sample count and incident transition. Use it to tune thresholds, diagnose flapping, or see the near-misses before a breach.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"},"group_key":{"type":"string","description":"Only checks for this exact group key"},"status":{"type":"string","enum":["breached","healthy","skipped","error"],"description":"Only checks with this status"},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 7 days before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"limit":{"type":"number","description":"Max checks to return (default 100, max 2000)"}},"required":["rule_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"ruleId":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"breached":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"healthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"skipped":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noData":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"belowMinSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errored":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"transitions":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"checks":{"type":"array","items":{"type":"object","properties":{"timestamp":{"type":"string"},"groupKey":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"observedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowStart":{"type":"string"},"windowEnd":{"type":"string"},"consecutiveBreaches":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"consecutiveHealthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"incidentId":{"anyOf":[{"type":"string"},{"type":"null"}]},"incidentTransition":{"type":"string"},"evaluationDurationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"errorMessage":{"anyOf":[{"type":"string"},{"type":"null"}]},"errorCategory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["timestamp","groupKey","status","skipReason","observedValue","threshold","comparator","sampleCount","windowStart","windowEnd","consecutiveBreaches","consecutiveHealthy","incidentId","incidentTransition","evaluationDurationMs","errorMessage","errorCategory"],"additionalProperties":true}},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"groupKey":{"type":"string"},"status":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["ruleId","total","breached","healthy","skipped","noData","belowMinSamples","errored","transitions","checks"],"additionalProperties":true},"annotations":{"title":"List Alert Checks","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"preview_alert_rule","title":"Preview Alert Rule","description":"Replay an alert rule over past data without saving it: per evaluation window, the value, sample count and verdict the scheduler would produce (breached, healthy, or skipped with the reason), plus when it would have fired. Pass rule_id to preview a saved rule, optionally with overrides, or a create_alert_rule definition to dry-run a new one. Run it before saving a raw_query rule.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Existing rule to preview (list_alert_rules). Definition params you also pass override its saved config, so a change can be tried before update_alert_rule. `template` is ignored with rule_id.: its UUID or its REST id (alrt_\u2026)"},"template":{"type":"string","enum":["high_error_rate","slow_p95","slow_p99","low_apdex","throughput_drop","custom"],"description":"Preset for signal_type, comparator and threshold; signal_type and comparator you pass are ignored, threshold overrides. high_error_rate: error_rate > 0.05, grouped by service.name unless services is set. slow_p95: p95_latency > 1000 ms. slow_p99: p99_latency > 2000 ms. low_apdex: apdex < 0.8 with apdex_threshold_ms 500. throughput_drop: throughput < 100 rpm. Omit (or pass custom) to set the three yourself."},"threshold":{"type":"number","description":"The value compared against. Units: error_rate and apdex are 0-1 ratios, latency is milliseconds, throughput is requests per minute. Overrides the template's."},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440 (default: 5)"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit (or pass \"auto\") to derive it: a fifth of the window, 1 to 5."},"services":{"type":"array","items":{"type":"string"},"description":"Service names to scope the alert to"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (e.g. 'production'). Omit for all environments. Ignored for builder_query / raw_query, which filter inside their own query."},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql. All other signals are computed over ROOT spans only: a service that fails on child spans but returns success from its entry point (cron jobs, workers; audit_setup STAT-04 lists them) reads healthy at any threshold. Use raw_query there or rely on error-issue notifications."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold (breach when `value threshold`). Ignored when a template is set."},"group_by":{"type":"array","items":{"type":"string"},"description":"Evaluate one value per group. Built-in tokens: service.name, span.name, status.code, http.method, severity; attribute keys as attr. (e.g. attr.http.route)."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window (default: 0). For raw_query it sums the `samples` column; without one each returned row counts as 1, so it gates on buckets, not events."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach, so a rule whose query stops matching opens an incident instead of going quiet (default false: such windows are skipped). Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting (default: 2)"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving (default: 2)"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes (default: 30)"},"apdex_threshold_ms":{"type":"number","description":"Response time counted as satisfactory, in ms. Required for signal_type=apdex."},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"The query to evaluate, in the query-builder draft shape dashboard custom-query widgets use ({ id, name, dataSource, aggregation, whereClause, groupBy, ... }). Required for signal_type=builder_query."},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column and optional `group` / `samples` columns (`samples` is the event count behind each row; it feeds minimum_sample_count). A query returning no rows is a no-data check, not a healthy one. Must reference $__orgFilter and $__timeFilter(col); $__startTime, $__endTime and $__interval_s are also available. At most 100 distinct groups. Required for signal_type=raw_query."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How several raw_query rows collapse into one value (default identity: the single row's value)."},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 1 day before end_time. Covers at most 7 days; a wider window is narrowed to the 7 days before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"ruleId":{"type":"string"},"timeRange":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"}},"required":["start","end"],"additionalProperties":true},"truncatedToStart":{"anyOf":[{"type":"string"},{"type":"null"}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"thresholdUpper":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"minimumSampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"groups":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"windows":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"breached":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"healthy":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noData":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"belowMinSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"noValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"minValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"maxValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"totalSamples":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","windows","breached","healthy","noData","belowMinSamples","noValue","minValue","maxValue","totalSamples"],"additionalProperties":true}},"points":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"bucket":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"provisional":{"type":"boolean"}},"required":["groupKey","bucket","status","skipReason","value","sampleCount","provisional"],"additionalProperties":true}},"wouldFire":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"start":{"type":"string"},"end":{"type":"string"}},"required":["groupKey","start","end"],"additionalProperties":true}},"warnings":{"type":"array","items":{"type":"string"}},"seriesFailure":{"type":"string","enum":["query_too_slow","query_too_much_memory","not_time_grouped"]},"queryTiming":{"type":"object","properties":{"elapsedMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"failure":{"anyOf":[{"type":"string"},{"type":"null"}]},"probeLimitMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"intervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limitMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"fitsIntervalMinutes":{"type":"array","items":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}}},"required":["elapsedMs","failure","probeLimitMs","intervalMinutes","limitMs","fitsIntervalMinutes"],"additionalProperties":true},"performanceHints":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"line":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["code","message","line"],"additionalProperties":true}}},"required":["timeRange","truncatedToStart","windowMinutes","comparator","threshold","thresholdUpper","minimumSampleCount","groups","points","wouldFire","warnings"],"additionalProperties":true},"annotations":{"title":"Preview Alert Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"get_incident_timeline","title":"Incident Timeline","description":"Alert-rule incidents (not error-issue incidents) for one rule or all, with first and last trigger, last notification and resolution timestamps: the per-incident detail behind list_alert_incidents. Pass rule_id to follow one rule over time.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Only incidents of this rule (ids from list_alert_rules): its UUID or its REST id (alrt_\u2026)"},"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"limit":{"type":"number","description":"Max incidents to return (default 20, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","comparator","threshold","lastObservedValue","firstTriggeredAt","lastTriggeredAt","resolvedAt","lastNotifiedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleId":{"type":"string"},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"acknowledged":{"type":"boolean"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"Incident Timeline","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"get_incident_timeline","title":"Incident Timeline","description":"Alert-rule incidents (not error-issue incidents) for one rule or all, with first and last trigger, last notification and resolution timestamps: the per-incident detail behind list_alert_incidents. Pass rule_id to follow one rule over time.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Only incidents of this rule (ids from list_alert_rules): its UUID or its REST id (alrt_\u2026)"},"status":{"type":"string","enum":["open","resolved"],"description":"Only incidents in this status"},"severity":{"type":"string","enum":["warning","critical"],"description":"Only incidents with this severity"},"group_key":{"type":"string","description":"Only incidents with this exact group key"},"limit":{"type":"number","description":"Max incidents to return (default 20, max 200)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"groupKey":{"anyOf":[{"type":"string"},{"type":"null"}]},"signalType":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastObservedValue":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"firstTriggeredAt":{"type":"string"},"lastTriggeredAt":{"type":"string"},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastNotifiedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceId":{"anyOf":[{"type":"string"},{"type":"null"}]},"silencedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"silenceReason":{"type":"string"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","ruleId","ruleName","groupKey","signalType","severity","status","comparator","threshold","lastObservedValue","firstTriggeredAt","lastTriggeredAt","resolvedAt","lastNotifiedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"openCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"resolvedCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"ruleId":{"type":"string"},"status":{"type":"string"},"severity":{"type":"string"},"groupKey":{"type":"string"},"acknowledged":{"type":"boolean"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["incidents","total","openCount","resolvedCount"],"additionalProperties":true},"annotations":{"title":"Incident Timeline","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_alert_rule","title":"Create Alert Rule","description":"Create an alert rule. Pick a `template` for the common cases; otherwise pass signal_type, comparator and threshold yourself. destination_ids is required (at least one, from list_alert_destinations).","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Rule name"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Required, at least one: destination IDs to notify, from list_alert_destinations. Each is its UUID or its REST id (dest_\u2026)"},"template":{"type":"string","enum":["high_error_rate","slow_p95","slow_p99","low_apdex","throughput_drop","custom"],"description":"Preset for signal_type, comparator and threshold; signal_type and comparator you pass are ignored, threshold overrides. high_error_rate: error_rate > 0.05, grouped by service.name unless services is set. slow_p95: p95_latency > 1000 ms. slow_p99: p99_latency > 2000 ms. low_apdex: apdex < 0.8 with apdex_threshold_ms 500. throughput_drop: throughput < 100 rpm. Omit (or pass custom) to set the three yourself."},"severity":{"type":"string","enum":["warning","critical"],"description":"Alert severity (default: warning)"},"threshold":{"type":"number","description":"The value compared against. Units: error_rate and apdex are 0-1 ratios, latency is milliseconds, throughput is requests per minute. Overrides the template's."},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440 (default: 5)"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit (or pass \"auto\") to derive it: a fifth of the window, 1 to 5."},"services":{"type":"array","items":{"type":"string"},"description":"Service names to scope the alert to"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (e.g. 'production'). Omit for all environments. Ignored for builder_query / raw_query, which filter inside their own query."},"enabled":{"type":"boolean","description":"Whether the rule evaluates (default true)"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql. All other signals are computed over ROOT spans only: a service that fails on child spans but returns success from its entry point (cron jobs, workers; audit_setup STAT-04 lists them) reads healthy at any threshold. Use raw_query there or rely on error-issue notifications."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold (breach when `value threshold`). Ignored when a template is set."},"group_by":{"type":"array","items":{"type":"string"},"description":"Evaluate one value per group. Built-in tokens: service.name, span.name, status.code, http.method, severity; attribute keys as attr. (e.g. attr.http.route)."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window (default: 0). For raw_query it sums the `samples` column; without one each returned row counts as 1, so it gates on buckets, not events."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach, so a rule whose query stops matching opens an incident instead of going quiet (default false: such windows are skipped). Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting (default: 2)"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving (default: 2)"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes (default: 30)"},"apdex_threshold_ms":{"type":"number","description":"Response time counted as satisfactory, in ms. Required for signal_type=apdex."},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"The query to evaluate, in the query-builder draft shape dashboard custom-query widgets use ({ id, name, dataSource, aggregation, whereClause, groupBy, ... }). Required for signal_type=builder_query."},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column and optional `group` / `samples` columns (`samples` is the event count behind each row; it feeds minimum_sample_count). A query returning no rows is a no-data check, not a healthy one. Must reference $__orgFilter and $__timeFilter(col); $__startTime, $__endTime and $__interval_s are also available. At most 100 distinct groups. Required for signal_type=raw_query."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How several raw_query rows collapse into one value (default identity: the single row's value)."},"notification_title":{"type":"string","description":"Custom notification title template. Supports {{ variable }} substitution, e.g. \"{{ event.emoji }} {{ rule.name }}: {{ event.label }}\". Omit for the built-in format. Variables: rule.name, severity, signal.label, comparator.label, threshold, value, observed.summary, group, window, links.app, links.chat."},"notification_body":{"type":"string","description":"Custom notification body template (Markdown). Supports {{ variable }} substitution and {{#if key}}\u2026{{/if}} blocks, e.g. \"*Severity:* {{ severity }}\\n*Observed:* {{ observed.summary }}\". Omit for the built-in format."}},"required":["name","destination_ids"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true},"warnings":{"type":"array","items":{"type":"string"}},"evaluation":{"type":"object","properties":{"nextEvaluationAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"current":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"window":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","window","status","skipReason","value","sampleCount"],"additionalProperties":true}},"previewError":{"type":"string"}},"required":["nextEvaluationAt","current"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Create Alert Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"update_alert_rule","title":"Update Alert Rule","description":"Update an alert rule. Pass only the fields to change; the rest keep their current value (get_alert_rule shows it). rule_ids applies one change to many rules (e.g. add_destination_ids on every rule with rule_ids=[\"*\"]). Ids from list_alert_rules and list_alert_destinations.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID to update (use list_alert_rules to find IDs): its UUID or its REST id (alrt_\u2026)"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Apply the same change to several rules in one call instead of rule_id; [\"*\"] means every rule. name cannot be set in bulk. Each is its UUID or its REST id (alrt_\u2026)"},"name":{"type":"string","description":"New rule name"},"severity":{"type":"string","enum":["warning","critical"],"description":"Alert severity"},"threshold":{"type":"number","description":"Threshold value. E.g. 0.05 for 5% error rate, 1000 for 1s latency"},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit to keep the current one; pass \"auto\" to derive it from the window (a fifth, 1 to 5). Any edit also clears a slowdown the scheduler applied."},"services":{"type":"array","items":{"type":"string"},"description":"Service scope (replaces the current one; an empty list means all services)"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (replaces the current scope; pass an empty list for all environments). Ignored for builder_query / raw_query."},"enabled":{"type":"boolean","description":"Whether the rule is enabled"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to notify, from list_alert_destinations; replaces the current ones. A rule needs at least one. Each is its UUID or its REST id (dest_\u2026)"},"add_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to add, keeping the current ones. Each is its UUID or its REST id (dest_\u2026)"},"remove_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to remove, keeping the rest. Each is its UUID or its REST id (dest_\u2026)"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold"},"group_by":{"type":"array","items":{"type":"string"},"description":"Dimensions to evaluate the alert per-group (replaces the current grouping; an empty list removes it). Built-in tokens: service.name, span.name, status.code, http.method, severity. Attribute keys: attr.."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window. For raw_query it sums the `samples` column; without one each returned row counts as 1."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach instead of skipping it. Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes"},"apdex_threshold_ms":{"type":"number","description":"Apdex threshold in milliseconds (for signal_type=apdex)"},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"Query-builder draft, same shape as create_alert_rule (for signal_type=builder_query)"},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column (for signal_type=raw_query), at most 100 groups. Must reference $__orgFilter and $__timeFilter(col)."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How to collapse raw_query result rows into one value."},"notification_title":{"type":"string","description":"Notification title template with {{ variable }} substitution; variables as in create_alert_rule"},"notification_body":{"type":"string","description":"Notification body template (Markdown) with {{ variable }} and {{#if key}}...{{/if}}"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true},"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true}},"warnings":{"type":"array","items":{"type":"string"}},"evaluation":{"type":"object","properties":{"nextEvaluationAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"current":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"window":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","window","status","skipReason","value","sampleCount"],"additionalProperties":true}},"previewError":{"type":"string"}},"required":["nextEvaluationAt","current"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Update Alert Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_alert_rule","title":"Delete Alert Rule","description":"Permanently delete an alert rule together with its incident history, delivery events and evaluation state. Irreversible; requires confirm=true. Ids from list_alert_rules.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["rule_id","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":true},"annotations":{"title":"Delete Alert Rule","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"list_alert_silences","title":"List Alert Silences","description":"List alert silences: windows that hold back alert notifications for matching rules (incidents still open and resolve). Defaults to active and scheduled silences; pass status=ended for past ones.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["scheduled","active","ended"],"description":"Only silences in this status (default: active and scheduled)"},"limit":{"type":"number","description":"Max silences to return (default 50, max 200)"},"offset":{"type":"number","description":"Rows to skip, for paging (default 0). Use the `next` call a truncated result gives."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silences":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"status":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"offset":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["silences","total","limit","hasMore"],"additionalProperties":true},"annotations":{"title":"List Alert Silences","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_alert_silence","title":"Create Alert Silence","description":"Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. Pass duration_minutes or ends_at (max 30 days).","inputSchema":{"type":"object","properties":{"reason":{"type":"string","description":"Why notifications are held back; shown on the silence and on held-back incidents"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"duration_minutes":{"type":"number","description":"How long the silence lasts from its start, in minutes"},"ends_at":{"type":"string","description":"When the silence ends, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"starts_at":{"type":"string","description":"When the silence starts (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["reason"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"Create Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"end_alert_silence","title":"End Alert Silence","description":"End an alert silence now so notifications resume; a scheduled one is cancelled. Ids from list_alert_silences.","inputSchema":{"type":"object","properties":{"silence_id":{"type":"string","description":"Silence to end: its UUID or its REST id (sil_\u2026)"}},"required":["silence_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"End Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"acknowledge_alert_incident","title":"Acknowledge Alert Incident","description":"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. Only open incidents; ids from list_alert_incidents.","inputSchema":{"type":"object","properties":{"incident_id":{"type":"string","description":"Alert incident ID: its UUID or its REST id (inc_\u2026)"},"acknowledged":{"type":"boolean","description":"false to unacknowledge. Default true."}},"required":["incident_id"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"ruleId":{"type":"string"},"ruleName":{"type":"string"},"status":{"type":"string"},"acknowledged":{"type":"boolean"},"acknowledgedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"acknowledgedBy":{"anyOf":[{"type":"string"},{"type":"null"}]},"changed":{"type":"boolean"}},"required":["id","ruleId","ruleName","status","acknowledged","acknowledgedAt","acknowledgedBy","changed"],"additionalProperties":true},"annotations":{"title":"Acknowledge Alert Incident","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"describe_dashboard_schema","title":"Describe Dashboard Schema","description":"What a dashboard widget can be: panel types, the four data-source kinds, the unit vocabulary, aggregations and group-by tokens, the display config, and (section templates) the create_dashboard templates. Generated from the live schema; read it before authoring or editing widgets rather than working from a remembered example.","inputSchema":{"type":"object","properties":{"section":{"type":"string","enum":["panel_types","data_sources","units","queries","display","raw_sql","templates"],"description":"Omit for the index plus the panel-type table."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"section":{"type":"string"},"markdown":{"type":"string"}},"required":["markdown"],"additionalProperties":true},"annotations":{"title":"Describe Dashboard Schema","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"list_dashboards","title":"List Dashboards","description":"List all dashboards with widget counts and timestamps. Use get_dashboard to see full widget configuration.","inputSchema":{"type":"object","properties":{"search":{"type":"string","description":"Filter dashboards by name (case-insensitive contains)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"dashboards":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"publicId":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"widgetCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","publicId","name","widgetCount","createdAt","updatedAt"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"search":{"type":"string"}},"required":["dashboards","total"],"additionalProperties":true},"annotations":{"title":"List Dashboards","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts new file mode 100644 index 0000000000..5b4cb121b6 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/alert-silence-tools.test.ts @@ -0,0 +1,349 @@ +/** + * The alert silence tools through the registry: matcher and window validation as invalid input, + * the request the service receives, the default status filter, and silences on the timeline. + */ +import { describe, expect, it } from "vitest" +import { Context, Effect, Layer, Schema, Array as Arr } from "effect" +import { + AlertIncidentsListResponse, + AlertSilenceCreateRequest, + AlertSilenceDocument, + AlertSilenceNotFoundError, + AlertValidationError, + AlertSilencesListResponse, + OrgId, + UserId, +} from "@maple/domain/http" +import { GetIncidentTimelineOutput, ListAlertSilencesOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { CurrentMcpTenant } from "../../lib/query-warehouse" +import { executeRegisteredMcpToolUnscoped } from "../registry" +import type { McpToolResult } from "../types" + +const RULE_ID = "7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" +const ACTIVE_ID = "a1111111-2222-4333-8444-555555555555" +const SCHEDULED_ID = "b1111111-2222-4333-8444-555555555555" +const ENDED_ID = "c1111111-2222-4333-8444-555555555555" +const INCIDENT_ID = "11111111-2222-4333-8444-555555555555" +const NOW = "2026-09-24T10:00:00.000Z" + +const silence = (id: string, status: "scheduled" | "active" | "ended", reason: string) => + Schema.decodeSync(AlertSilenceDocument)({ + id, + reason, + matchers: { services: ["checkout"] }, + status, + startsAt: NOW, + endsAt: "2026-09-24T12:00:00.000Z", + createdBy: "user_tools", + createdAt: NOW, + updatedAt: NOW, + }) + +const stored = [ + silence(ACTIVE_ID, "active", "Checkout deploy"), + silence(SCHEDULED_ID, "scheduled", "DB maintenance"), + silence(ENDED_ID, "ended", "Old outage"), +] + +interface Seen { + created?: AlertSilenceCreateRequest + listedStatuses: Array + ended?: string + /** The service refuses the create with this, for one test. */ + createFails?: AlertValidationError + /** More silences beside `stored`, for one test. */ + extra?: ReadonlyArray +} + +const layer = (seen: Seen) => + Layer.mergeAll( + Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeSync(OrgId)("org_tools"), + userId: Schema.decodeSync(UserId)("user_tools"), + roles: [], + authMode: "self_hosted", + } as never), + Layer.succeed(AlertSilencesService, { + list: (_org: unknown, options: { readonly status?: string } = {}) => { + seen.listedStatuses.push(options.status) + return Effect.succeed( + new AlertSilencesListResponse({ + silences: Arr.filter( + [...stored, ...(seen.extra ?? [])], + (s) => options.status === undefined || s.status === options.status, + ), + }), + ) + }, + get: (_org: unknown, id: string) => { + const found = Arr.findFirst(stored, (s) => s.id === id) + return found._tag === "Some" + ? Effect.succeed(found.value) + : Effect.fail( + new AlertSilenceNotFoundError({ message: "not found", silenceId: id as never }), + ) + }, + create: (_org: unknown, _user: unknown, _roles: unknown, request: AlertSilenceCreateRequest) => { + if (seen.createFails !== undefined) return Effect.fail(seen.createFails) + seen.created = request + return Effect.succeed( + Schema.decodeSync(AlertSilenceDocument)({ + ...Schema.encodeSync(AlertSilenceDocument)(stored[0]!), + reason: request.reason, + matchers: request.matchers, + endsAt: request.endsAt, + }), + ) + }, + end: (_org: unknown, _user: unknown, _roles: unknown, id: string) => { + seen.ended = id + return id === ACTIVE_ID + ? Effect.succeed(silence(ACTIVE_ID, "ended", "Checkout deploy")) + : Effect.fail( + new AlertSilenceNotFoundError({ message: "not found", silenceId: id as never }), + ) + }, + } as never), + Layer.succeed(AlertReadModelsService, { + listIncidents: () => + Effect.succeed( + Schema.decodeSync(AlertIncidentsListResponse)({ + incidents: [ + { + id: INCIDENT_ID, + ruleId: RULE_ID, + ruleName: "Checkout errors", + groupKey: "checkout", + signalType: "error_rate", + severity: "critical", + status: "open", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + peakSeverity: null, + firstTriggeredAt: NOW, + lastTriggeredAt: NOW, + resolvedAt: null, + lastObservedValue: 0.12, + lastSampleCount: 100, + dedupeKey: "k", + lastDeliveredEventType: null, + lastNotifiedAt: null, + holdReason: null, + heldSince: null, + errorIssueId: null, + silenceId: ACTIVE_ID, + silencedAt: NOW, + acknowledgedAt: null, + acknowledgedByUserId: null, + acknowledgedByName: null, + acknowledgedVia: null, + }, + ], + }), + ), + } as never), + ) + +// The registry's requirement union names every tool's services; these tools reach only the +// ones `layer` provides, so the rest are left absent (a defect if ever reached). +const withoutUnreachedServices = (effect: Effect.Effect): Effect.Effect => + Effect.provide(effect, Context.makeUnsafe(new Map())) + +const run = (name: string, params: unknown, seen: Seen) => + Effect.runPromise( + executeRegisteredMcpToolUnscoped(name, params, "mcp").pipe( + Effect.provide(layer(seen)), + Effect.result, + withoutUnreachedServices, + ), + ) + +const ok = async (name: string, params: unknown, seen: Seen) => { + const result = await run(name, params, seen) + if (result._tag !== "Success") throw new Error(`${name} failed: ${JSON.stringify(result.failure)}`) + return result.success +} + +const invalidParameter = (result: Awaited>) => + result._tag === "Failure" && "parameter" in result.failure ? result.failure.parameter : undefined + +const text = (result: McpToolResult) => result.content[0]?.text ?? "" +const fresh = (): Seen => ({ listedStatuses: [] }) + +describe("alert silence tools", () => { + it("create_alert_silence turns a duration into an end time and passes the matchers", async () => { + const seen = fresh() + const result = await ok( + "create_alert_silence", + { + reason: "Checkout deploy", + rule_ids: [RULE_ID], + services: "checkout", + tags: [], + duration_minutes: "90", + }, + seen, + ) + expect(seen.created?.matchers).toEqual({ ruleIds: [RULE_ID], services: ["checkout"] }) + expect(seen.created?.startsAt).toBeUndefined() + const endsInMs = Date.parse(seen.created?.endsAt ?? "") - Date.now() + expect(endsInMs).toBeGreaterThan(89 * 60_000) + expect(endsInMs).toBeLessThanOrEqual(90 * 60_000) + expect(text(result)).toContain("Reason: Checkout deploy") + expect(text(result)).toContain(`\`end_alert_silence silence_id="${ACTIVE_ID}"\``) + }) + + it("create_alert_silence ends a duration after an explicit start", async () => { + const seen = fresh() + await ok( + "create_alert_silence", + { + reason: "Window", + tags: ["payments"], + starts_at: "2030-01-01 00:00:00", + duration_minutes: 30, + }, + seen, + ) + expect(seen.created?.startsAt).toBe("2030-01-01T00:00:00.000Z") + expect(seen.created?.endsAt).toBe("2030-01-01T00:30:00.000Z") + }) + + it("create_alert_silence names the rule ids the service could not find", async () => { + const seen: Seen = { + ...fresh(), + createFails: new AlertValidationError({ + message: "Unknown alert rule in silence matchers", + details: [`Alert rule ${RULE_ID} does not exist`], + }), + } + const result = await run( + "create_alert_silence", + { reason: "x", rule_ids: [RULE_ID], duration_minutes: 30 }, + seen, + ) + expect(result._tag === "Failure" ? result.failure.message : "").toContain( + `Alert rule ${RULE_ID} does not exist`, + ) + }) + + it("create_alert_silence refuses an absurd duration before any date maths", async () => { + const seen = fresh() + const result = await run( + "create_alert_silence", + { reason: "x", tags: ["a"], duration_minutes: 1e12 }, + seen, + ) + expect(invalidParameter(result)).toBe("duration_minutes") + expect(seen.created).toBeUndefined() + }) + + it("list_alert_silences pages one status with an offset", async () => { + const seen: Seen = { + ...fresh(), + extra: Arr.map(["d", "e", "f"], (prefix) => + silence(`${prefix}1111111-2222-4333-8444-555555555555`, "active", `Deploy ${prefix}`), + ), + } + const result = await ok("list_alert_silences", { status: "active", limit: 2, offset: 0 }, seen) + const output = Schema.decodeUnknownSync(ListAlertSilencesOutput)(result.structuredContent) + expect(output.hasMore).toBe(true) + expect(text(result)).toContain("offset=2") + }) + + it("create_alert_silence refuses a silence with no matcher", async () => { + const seen = fresh() + const result = await run( + "create_alert_silence", + { reason: "Everything", services: [], duration_minutes: 30 }, + seen, + ) + expect(invalidParameter(result)).toBe("rule_ids") + expect(seen.created).toBeUndefined() + }) + + it("create_alert_silence needs exactly one of duration_minutes and ends_at", async () => { + const seen = fresh() + const both = await run( + "create_alert_silence", + { reason: "x", tags: ["a"], duration_minutes: 30, ends_at: "2030-01-01T00:00:00Z" }, + seen, + ) + expect(invalidParameter(both)).toBe("duration_minutes") + const neither = await run("create_alert_silence", { reason: "x", tags: ["a"] }, seen) + expect(invalidParameter(neither)).toBe("duration_minutes") + expect(seen.created).toBeUndefined() + }) + + it("create_alert_silence passes an ends_at alone through, starting now", async () => { + const seen = fresh() + await ok( + "create_alert_silence", + { reason: "Migration", environments: ["production"], ends_at: "2030-01-01 06:00:00" }, + seen, + ) + expect(seen.created?.matchers).toEqual({ environments: ["production"] }) + expect(seen.created?.startsAt).toBeUndefined() + expect(seen.created?.endsAt).toBe("2030-01-01T06:00:00.000Z") + }) + + it("create_alert_silence refuses a duration that is not positive", async () => { + const seen = fresh() + for (const duration_minutes of [0, -15]) { + const result = await run( + "create_alert_silence", + { reason: "x", tags: ["a"], duration_minutes }, + seen, + ) + expect(invalidParameter(result)).toBe("duration_minutes") + } + expect(seen.created).toBeUndefined() + }) + + it("list_alert_silences shows active and scheduled silences by default, not ended ones", async () => { + const seen = fresh() + const result = await ok("list_alert_silences", {}, seen) + const output = Schema.decodeUnknownSync(ListAlertSilencesOutput)(result.structuredContent) + expect(Arr.map(output.silences, (s) => s.id)).toEqual([ACTIVE_ID, SCHEDULED_ID]) + expect(output.status).toBeUndefined() + expect(text(result)).not.toContain("Old outage") + + const ended = await ok("list_alert_silences", { status: "ended" }, fresh()) + expect(text(ended)).toContain("Old outage") + }) + + it("list_alert_silences keeps scheduled silences in view behind a full page of active ones", async () => { + const seen: Seen = { + ...fresh(), + extra: Arr.map(["d", "e", "f"], (prefix) => + silence(`${prefix}1111111-2222-4333-8444-555555555555`, "active", `Deploy ${prefix}`), + ), + } + const result = await ok("list_alert_silences", { limit: 2 }, seen) + const output = Schema.decodeUnknownSync(ListAlertSilencesOutput)(result.structuredContent) + expect(Arr.map(output.silences, (s) => s.status)).toEqual(["active", "scheduled"]) + expect(output.hasMore).toBe(true) + }) + + it("end_alert_silence ends by id and maps an unknown one to silence_id", async () => { + const seen = fresh() + const result = await ok("end_alert_silence", { silence_id: ACTIVE_ID }, seen) + expect(seen.ended).toBe(ACTIVE_ID) + expect(text(result)).toContain("Status: ended") + + const missing = await run("end_alert_silence", { silence_id: INCIDENT_ID }, fresh()) + expect(invalidParameter(missing)).toBe("silence_id") + const malformed = await run("end_alert_silence", { silence_id: "nope" }, fresh()) + expect(invalidParameter(malformed)).toBe("silence_id") + }) + + it("get_incident_timeline names the silence holding back an incident's notifications", async () => { + const result = await ok("get_incident_timeline", {}, fresh()) + const output = Schema.decodeUnknownSync(GetIncidentTimelineOutput)(result.structuredContent) + expect(output.incidents[0]?.silenceReason).toBe("Checkout deploy") + expect(text(result)).toContain(`held back by silence "Checkout deploy" (${ACTIVE_ID})`) + }) +}) diff --git a/apps/ai/src/mcp/tools/create-alert-silence.ts b/apps/ai/src/mcp/tools/create-alert-silence.ts new file mode 100644 index 0000000000..df41f097d5 --- /dev/null +++ b/apps/ai/src/mcp/tools/create-alert-silence.ts @@ -0,0 +1,125 @@ +import { McpInvalidInputError, type McpToolRegistrar } from "./types" +import { Clock, DateTime, Effect, Schema } from "effect" +import { AlertSilenceCreateRequest } from "@maple/domain/http" +import { AlertSilenceMutationOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { parseWarehouseDateTime } from "@maple/query-engine" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { silenceFields, createSilenceErrors, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const decodeCreateRequest = Schema.decodeUnknownEffect(AlertSilenceCreateRequest) +const MAX_DURATION_MINUTES = 30 * 24 * 60 +const isoAt = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)) + +/** Only the matcher lists that hold a value, so an empty list never reaches the service. */ +const nonEmpty = (key: K, values: ReadonlyArray | undefined) => + values === undefined || values.length === 0 ? undefined : { [key]: values } + +export function registerCreateAlertSilenceTool(server: McpToolRegistrar) { + server.define({ + name: "create_alert_silence", + title: "Create Alert Silence", + description: + "Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. " + + "Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. " + + "Pass duration_minutes or ends_at (max 30 days).", + parameters: Schema.Struct({ + reason: P.text( + "Why notifications are held back; shown on the silence and on held-back incidents", + ), + rule_ids: P.optionalResourceIdList( + "alertRule", + "Silence these alert rules (ids from list_alert_rules)", + ), + tags: P.optionalList("Silence rules carrying any of these tags"), + services: P.optionalList("Silence rules and incident groups covered by these services"), + environments: P.optionalList("Silence rules and incident groups covered by these environments"), + duration_minutes: P.optionalNumber("How long the silence lasts from its start, in minutes"), + ends_at: P.optionalTimestamp("When the silence ends"), + starts_at: P.optionalTimestamp("When the silence starts (default: now)"), + }), + aliases: { service_names: "services" }, + output: AlertSilenceMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Creating an alert silence", "Silencing alerts"], + handler: Effect.fn("McpTool.createAlertSilence")(function* (params) { + if ((params.duration_minutes === undefined) === (params.ends_at === undefined)) { + return yield* new McpInvalidInputError({ + message: + params.ends_at === undefined + ? "Say when the silence ends: pass duration_minutes or ends_at." + : "Pass duration_minutes or ends_at, not both.", + parameter: "duration_minutes", + }) + } + // Checked before any date maths: an absurd duration would overflow the end time. + if (params.duration_minutes !== undefined && params.duration_minutes > MAX_DURATION_MINUTES) { + return yield* new McpInvalidInputError({ + message: "A silence can last at most 30 days (43200 minutes).", + parameter: "duration_minutes", + }) + } + if (params.duration_minutes !== undefined && params.duration_minutes <= 0) { + return yield* new McpInvalidInputError({ + message: "duration_minutes must be positive.", + parameter: "duration_minutes", + }) + } + + const ruleIds = yield* Effect.forEach( + params.rule_ids ?? [], + P.decodeResourceId("alertRule", "rule_ids"), + ) + const matchers = { + ...nonEmpty("ruleIds", ruleIds), + ...nonEmpty("tags", params.tags), + ...nonEmpty("services", params.services), + ...nonEmpty("environments", params.environments), + } + if (Object.keys(matchers).length === 0) { + return yield* new McpInvalidInputError({ + message: + "A silence needs at least one matcher: rule_ids, tags, services or environments. Use list_alert_rules to find them.", + parameter: "rule_ids", + }) + } + + const nowMs = yield* Clock.currentTimeMillis + const startsAtMs = + params.starts_at === undefined ? nowMs : parseWarehouseDateTime(params.starts_at) + const endsAtMs = + params.ends_at === undefined + ? startsAtMs + (params.duration_minutes ?? 0) * 60_000 + : parseWarehouseDateTime(params.ends_at) + const request = yield* decodeCreateRequest({ + reason: params.reason, + matchers, + ...(params.starts_at === undefined ? undefined : { startsAt: isoAt(startsAtMs) }), + endsAt: isoAt(endsAtMs), + }).pipe( + Effect.mapError( + (error) => + new McpInvalidInputError({ message: `Invalid alert silence: ${String(error)}` }), + ), + ) + + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const silence = yield* silences + .create(tenant.orgId, tenant.userId, tenant.roles, request) + .pipe(Effect.catchTags(createSilenceErrors)) + + return { silence: toAlertSilenceRow(silence) } + }), + render: (output) => ({ + title: "Alert Silence Created", + blocks: [silenceFields(output.silence)], + next: [ + doc.next("list_alert_silences", {}, "every active and scheduled silence"), + doc.next("end_alert_silence", { silence_id: output.silence.id }, "end it early"), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/end-alert-silence.ts b/apps/ai/src/mcp/tools/end-alert-silence.ts new file mode 100644 index 0000000000..f9c2c0fb2a --- /dev/null +++ b/apps/ai/src/mcp/tools/end-alert-silence.ts @@ -0,0 +1,37 @@ +import type { McpToolRegistrar } from "./types" +import { Effect, Schema } from "effect" +import { AlertSilenceMutationOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { silenceFields, endSilenceErrors, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +export function registerEndAlertSilenceTool(server: McpToolRegistrar) { + server.define({ + name: "end_alert_silence", + title: "End Alert Silence", + description: + "End an alert silence now so notifications resume; a scheduled one is cancelled. Ids from list_alert_silences.", + parameters: Schema.Struct({ + silence_id: P.resourceId("alertSilence", "Silence to end"), + }), + output: AlertSilenceMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Ending an alert silence"], + handler: Effect.fn("McpTool.endAlertSilence")(function* (params) { + const silenceId = yield* P.decodeResourceId("alertSilence", "silence_id")(params.silence_id) + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const silence = yield* silences + .end(tenant.orgId, tenant.userId, tenant.roles, silenceId) + .pipe(Effect.catchTags(endSilenceErrors)) + return { silence: toAlertSilenceRow(silence) } + }), + render: (output) => ({ + title: "Alert Silence Ended", + blocks: [silenceFields(output.silence)], + next: [doc.next("list_alert_silences", {}, "the silences still active or scheduled")], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index d7a4e841b0..6c8c329c73 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -1,8 +1,10 @@ import { McpQueryError, type McpToolRegistrar } from "./types" -import { Effect, Schema, Array as Arr, String as Str } from "effect" +import { Effect, Option, Schema, Array as Arr, String as Str } from "effect" import { GetIncidentTimelineOutput } from "@maple/domain/mcp-outputs" import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import type { AlertSilenceId, OrgId } from "@maple/domain/primitives" import { ackLabel, ALERT_INCIDENT_STATUSES, @@ -23,6 +25,45 @@ function formatTimestamp(iso: string | null): string | undefined { return Str.replace("T", " ")(Str.slice(0, 19)(iso)) } +/** Reasons of the silences these incidents name; one that cannot be read is left out. */ +const silenceReasons = Effect.fn("McpTool.getIncidentTimeline.silenceReasons")(function* ( + orgId: OrgId, + ids: ReadonlyArray, +) { + if (ids.length === 0) return new Map() + const silences = yield* AlertSilencesService + const found = yield* Effect.forEach( + ids, + (id) => + silences.get(orgId, id).pipe( + Effect.map((silence) => [[silence.id, silence.reason] as const]), + // The line falls back to the bare id, but a failed lookup is still recorded. + Effect.tapError((error) => + Effect.logWarning("Alert silence reason lookup failed").pipe( + Effect.annotateLogs({ silenceId: id, errorTag: error._tag }), + ), + ), + Effect.orElseSucceed(() => []), + ), + { concurrency: 4 }, + ) + return new Map(Arr.flatten(found)) +}) + +/** How a held-back notification reads: still owed while `silencedAt` is set. */ +function silenceLine(inc: { + readonly silenceId?: string | null + readonly silencedAt?: string | null + readonly silenceReason?: string +}): string | undefined { + if (inc.silenceId === undefined || inc.silenceId === null) return undefined + const named = + inc.silenceReason === undefined ? inc.silenceId : `"${inc.silenceReason}" (${inc.silenceId})` + return inc.silencedAt === undefined || inc.silencedAt === null + ? `a page was held back earlier by silence ${named}; nothing is owed now` + : `held back by silence ${named} since ${formatTimestamp(inc.silencedAt)}` +} + export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { server.define({ name: "get_incident_timeline", @@ -72,6 +113,10 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { (params.group_key === undefined || i.groupKey === params.group_key), ) const incidents = matching.slice(0, params.limit) + const reasons = yield* silenceReasons( + tenant.orgId, + Arr.dedupe(Arr.flatMap(incidents, (i) => (i.silenceId === null ? [] : [i.silenceId]))), + ) return { incidents: Arr.map(incidents, (i) => ({ @@ -89,6 +134,12 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { lastTriggeredAt: i.lastTriggeredAt, resolvedAt: i.resolvedAt, lastNotifiedAt: i.lastNotifiedAt, + silenceId: i.silenceId, + silencedAt: i.silencedAt, + ...Option.match(Option.fromUndefinedOr(reasons.get(i.silenceId ?? "")), { + onNone: () => undefined, + onSome: (silenceReason) => ({ silenceReason }), + }), acknowledgedAt: i.acknowledgedAt, acknowledgedBy: ackLabel(i), })), @@ -135,6 +186,7 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ? undefined : `${formatTimestamp(inc.acknowledgedAt)} by ${inc.acknowledgedBy ?? "someone"}`, ], + ["Notifications", silenceLine(inc)], ["Incident ID", inc.id], ["Rule ID", inc.ruleId], ]), diff --git a/apps/ai/src/mcp/tools/list-alert-silences.ts b/apps/ai/src/mcp/tools/list-alert-silences.ts new file mode 100644 index 0000000000..bdc35385b5 --- /dev/null +++ b/apps/ai/src/mcp/tools/list-alert-silences.ts @@ -0,0 +1,148 @@ +import type { McpToolRegistrar } from "./types" +import { Array as Arr, Effect, Schema } from "effect" +import { ListAlertSilencesOutput } from "@maple/domain/mcp-outputs" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { toMcpHttpError } from "../lib/map-http-error" +import { ALERT_SILENCE_STATUSES, formatSilenceMatchers, toAlertSilenceRow } from "../lib/alert-silences" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const MAX_LIMIT = 200 + +export function registerListAlertSilencesTool(server: McpToolRegistrar) { + server.define({ + name: "list_alert_silences", + title: "List Alert Silences", + description: + "List alert silences: windows that hold back alert notifications for matching rules (incidents still open and resolve). " + + "Defaults to active and scheduled silences; pass status=ended for past ones.", + parameters: Schema.Struct({ + status: P.optionalOneOf( + ALERT_SILENCE_STATUSES, + "Only silences in this status (default: active and scheduled)", + ), + limit: P.limit({ default: 50, max: MAX_LIMIT, noun: "silences" }), + offset: P.offset({ max: 10_000 }), + }), + output: ListAlertSilencesOutput, + hints: { readOnly: true }, + phrases: ["Listing alert silences", "Checking alert silences"], + handler: Effect.fn("McpTool.listAlertSilences")(function* (params) { + const tenant = yield* CurrentMcpTenant + const silences = yield* AlertSilencesService + const statuses = + params.status === undefined ? (["active", "scheduled"] as const) : [params.status] + + const pages = yield* Effect.forEach(statuses, (status) => + silences + .list(tenant.orgId, { + status, + limit: params.limit + 1, + // Paging is for one status; the default view shares one window between two. + ...(params.status === undefined ? undefined : { offset: params.offset }), + }) + .pipe(Effect.mapError(toMcpHttpError("list_alert_silences"))), + ) + // Both statuses share the window: a full page of active silences must not hide every + // scheduled one, so scheduled keeps at least half of it when it has that many. + const [first = [], second = []] = Arr.map(pages, (page) => page.silences) + const secondShare = Math.min( + second.length, + Math.max(params.limit - first.length, Math.floor(params.limit / 2)), + ) + const shown = [...first.slice(0, params.limit - secondShare), ...second.slice(0, secondShare)] + const total = first.length + second.length + + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "maple.ai.silence_status": params.status ?? "open", + "result.rowCount": shown.length, + }) + + return { + silences: Arr.map(shown, toAlertSilenceRow), + total: shown.length, + ...(params.status === undefined ? undefined : { status: params.status }), + limit: params.limit, + ...(params.status === undefined ? undefined : { offset: params.offset }), + hasMore: total > shown.length, + } + }), + render: (output) => ({ + title: "Alert Silences", + scope: [["Status", output.status ?? "active, scheduled"]], + ...(output.silences.length === 0 + ? { + empty: { + message: + output.status === undefined + ? "No active or scheduled alert silences." + : `No ${output.status} alert silences.`, + hints: ["Create one with create_alert_silence to hold back notifications."], + }, + } + : undefined), + blocks: + output.silences.length === 0 + ? [] + : [ + doc.table( + ["ID", "Status", "Reason", "Matches", "Starts", "Ends", "Created by"], + Arr.map(output.silences, (s) => [ + s.id, + s.status, + s.reason, + formatSilenceMatchers(s.matchers), + s.startsAt, + s.endsAt, + s.createdBy, + ]), + ), + ], + ...(output.hasMore + ? { + truncation: { + shown: output.silences.length, + noun: "silences", + ...(output.status !== undefined + ? { + next: doc.next( + "list_alert_silences", + { + status: output.status, + limit: output.limit, + offset: (output.offset ?? 0) + output.silences.length, + }, + "the next page", + ), + } + : output.limit < MAX_LIMIT + ? { + next: doc.next( + "list_alert_silences", + { limit: MAX_LIMIT }, + "more silences", + ), + } + : output.status === undefined + ? { + next: doc.next( + "list_alert_silences", + { status: "scheduled", limit: MAX_LIMIT }, + "the scheduled silences on their own", + ), + } + : undefined), + }, + } + : undefined), + next: [ + ...Arr.map(Arr.filter(output.silences, (s) => s.status !== "ended").slice(0, 1), (s) => + doc.next("end_alert_silence", { silence_id: s.id }, `end "${s.reason}" now`), + ), + doc.next("list_alert_rules", {}, "find rule ids and tags to silence"), + ], + }), + }) +} diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index ca462fe5b3..5afd22ffbf 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -18,6 +18,8 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_alert_rule", "update_alert_rule", "delete_alert_rule", + "create_alert_silence", + "end_alert_silence", "acknowledge_alert_incident", // error issues "claim_error_issue", diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 816e94958a..10639880a5 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -10,6 +10,9 @@ import { registerCreateAlertRuleTool } from "./create-alert-rule" import { registerPreviewAlertRuleTool } from "./preview-alert-rule" import { registerUpdateAlertRuleTool } from "./update-alert-rule" import { registerDeleteAlertRuleTool } from "./delete-alert-rule" +import { registerListAlertSilencesTool } from "./list-alert-silences" +import { registerCreateAlertSilenceTool } from "./create-alert-silence" +import { registerEndAlertSilenceTool } from "./end-alert-silence" import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident" import { registerCreateDashboardTool } from "./create-dashboard" import { registerDescribeDashboardSchemaTool } from "./describe-dashboard-schema" @@ -431,6 +434,9 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerCreateAlertRuleTool(registrar) registerUpdateAlertRuleTool(registrar) registerDeleteAlertRuleTool(registrar) + registerListAlertSilencesTool(registrar) + registerCreateAlertSilenceTool(registrar) + registerEndAlertSilenceTool(registrar) registerAcknowledgeAlertIncidentTool(registrar) registerDescribeDashboardSchemaTool(registrar) registerListDashboardsTool(registrar) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index a42d9fa308..b9b269040f 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -4,6 +4,7 @@ import type { AlertsService } from "@maple/backend/services/alerts/AlertsService import type { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import type { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import type { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import type { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import type { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import type { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import type { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -49,6 +50,7 @@ export type McpToolRuntimeRequirements = | AlertReadModelsService | AlertIncidentAckService | AlertRulesService + | AlertSilencesService | DashboardPersistenceService | ErrorActorsService | ErrorIssueReadModelsService diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index f367fe1373..2e917f87ba 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -7,6 +7,7 @@ import { AlertsService } from "@maple/backend/services/alerts/AlertsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" +import { AlertSilencesService } from "@maple/backend/services/alerts/AlertSilencesService" import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import { ErrorIssueReadModelsService } from "@maple/backend/services/errors/ErrorIssueReadModelsService" @@ -45,6 +46,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(AlertReadModelsService)({}), Layer.mock(AlertIncidentAckService)({}), Layer.mock(AlertRulesService)({}), + Layer.mock(AlertSilencesService)({}), Layer.mock(DashboardPersistenceService)({}), Layer.mock(ErrorActorsService)({}), Layer.mock(ErrorIssueReadModelsService)({}), diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index 82a9aba088..77e2f3d0f7 100644 --- a/apps/landing/src/content/docs/reference/mcp.md +++ b/apps/landing/src/content/docs/reference/mcp.md @@ -114,6 +114,9 @@ Tools that take an alert rule, alert destination, dashboard or error issue ID ac | `create_alert_rule` | write | Create an alert rule from a template or from signal type, comparator and threshold | | `update_alert_rule` | write | Change the fields you pass on an alert rule | | `delete_alert_rule` | write | Delete an alert rule and its incident history. Requires `confirm=true` | +| `list_alert_silences` | read | Active and scheduled alert silences, or ended ones, with their matchers and window | +| `create_alert_silence` | write | Hold back notifications for matching rules for a duration or until a time | +| `end_alert_silence` | write | End a silence now so notifications resume; cancels a scheduled one | ### Dashboards diff --git a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts index a9f025bd53..f89d163d54 100644 --- a/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts +++ b/apps/web/src/page/investigations/conversation/kit/tool-metadata.ts @@ -60,6 +60,9 @@ const TOOL_LABELS = new Map([ ["create_alert_rule", "Create Alert Rule"], ["update_alert_rule", "Update Alert Rule"], ["delete_alert_rule", "Delete Alert Rule"], + ["list_alert_silences", "List Alert Silences"], + ["create_alert_silence", "Create Alert Silence"], + ["end_alert_silence", "End Alert Silence"], ["list_alert_incidents", "List Alert Incidents"], ["list_alert_checks", "List Alert Checks"], ["preview_alert_rule", "Preview Alert Rule"], @@ -129,6 +132,9 @@ const TOOL_ICONS = new Map([ ["create_alert_rule", "BellIcon"], ["update_alert_rule", "BellIcon"], ["delete_alert_rule", "TrashIcon"], + ["list_alert_silences", "BellIcon"], + ["create_alert_silence", "BellIcon"], + ["end_alert_silence", "BellIcon"], ["list_alert_incidents", "AlertWarningIcon"], ["list_alert_checks", "CircleCheckIcon"], ["preview_alert_rule", "HistoryIcon"], diff --git a/packages/domain/src/mcp-outputs/alert-silences.ts b/packages/domain/src/mcp-outputs/alert-silences.ts new file mode 100644 index 0000000000..a674fa6faf --- /dev/null +++ b/packages/domain/src/mcp-outputs/alert-silences.ts @@ -0,0 +1,35 @@ +/** Output schemas for the alert silence MCP tools. */ +import { Schema } from "effect" + +const MatcherValues = Schema.optionalKey(Schema.Array(Schema.String)) + +export const AlertSilenceMatchersRow = Schema.Struct({ + ruleIds: MatcherValues, + tags: MatcherValues, + services: MatcherValues, + environments: MatcherValues, +}) + +export const AlertSilenceRow = Schema.Struct({ + id: Schema.String, + reason: Schema.String, + matchers: AlertSilenceMatchersRow, + /** scheduled, active or ended. */ + status: Schema.String, + startsAt: Schema.String, + endsAt: Schema.String, + createdBy: Schema.String, +}) + +export const ListAlertSilencesOutput = Schema.Struct({ + silences: Schema.Array(AlertSilenceRow), + total: Schema.Number, + /** The status filter; absent means active and scheduled. */ + status: Schema.optionalKey(Schema.String), + limit: Schema.Number, + /** Rows skipped, when a single status is paged. */ + offset: Schema.optionalKey(Schema.Number), + hasMore: Schema.Boolean, +}) + +export const AlertSilenceMutationOutput = Schema.Struct({ silence: AlertSilenceRow }) diff --git a/packages/domain/src/mcp-outputs/alerts.ts b/packages/domain/src/mcp-outputs/alerts.ts index 17e5776ed7..b13da890d1 100644 --- a/packages/domain/src/mcp-outputs/alerts.ts +++ b/packages/domain/src/mcp-outputs/alerts.ts @@ -302,6 +302,12 @@ export const IncidentTimelineRow = Schema.Struct({ lastTriggeredAt: Schema.String, resolvedAt: NullableString, lastNotifiedAt: NullableString, + /** The silence that last held back a notification for this incident. */ + silenceId: Schema.optionalKey(NullableString), + /** Set while a silenced notification is still owed. */ + silencedAt: Schema.optionalKey(NullableString), + /** The silence's reason, when it could be read. */ + silenceReason: Schema.optionalKey(Schema.String), acknowledgedAt: Schema.optionalKey(NullableString), acknowledgedBy: Schema.optionalKey(NullableString), }) diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index 40336a2c04..21ee57aedc 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -34,6 +34,7 @@ import { SyncRailwayOutput, } from "./integrations" import { CiRunsOutput, CompareCommitsOutput } from "./ci" +import { AlertSilenceMutationOutput, ListAlertSilencesOutput } from "./alert-silences" import { AcknowledgeAlertIncidentOutput, CreateAlertRuleOutput, @@ -141,6 +142,7 @@ export const McpToolOutputs = { compare_commits: CompareCommitsOutput, compare_periods: ComparePeriodsOutput, create_alert_rule: CreateAlertRuleOutput, + create_alert_silence: AlertSilenceMutationOutput, create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, @@ -157,6 +159,7 @@ export const McpToolOutputs = { describe_dashboard_schema: DescribeDashboardSchemaOutput, describe_warehouse_tables: DescribeWarehouseTablesOutput, diagnose_service: DiagnoseServiceOutput, + end_alert_silence: AlertSilenceMutationOutput, error_detail: ErrorDetailOutput, explore_attributes: ExploreAttributesOutput, find_errors: FindErrorsOutput, @@ -190,6 +193,7 @@ export const McpToolOutputs = { list_alert_destinations: ListAlertDestinationsOutput, list_alert_incidents: ListAlertIncidentsOutput, list_alert_rules: ListAlertRulesOutput, + list_alert_silences: ListAlertSilencesOutput, list_dashboards: ListDashboardsOutput, list_error_incidents: ListErrorIncidentsOutput, list_error_issue_events: ListErrorIssueEventsOutput, diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 54c0677436..8bb48e0385 100644 --- a/packages/domain/src/mcp-outputs/index.ts +++ b/packages/domain/src/mcp-outputs/index.ts @@ -4,6 +4,7 @@ export * from "./traces" export * from "./services" export * from "./sessions" export * from "./alerts" +export * from "./alert-silences" export * from "./ingest-rules" export * from "./dashboards" export * from "./issues"