From 21dd679a630b16278ceb6fd1434e4d82d18dafab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:27:28 +0100 Subject: [PATCH 1/6] feat(mcp): SLO tools: list, inspect, create, update, delete list_slos shows each SLO's SLI, budget left and worst burn rate, breaching tiers first. inspect_slo shows the 5m/30m/1h/6h burn windows, which tier breaches and the alerting, and points at the rule's incident timeline. Writes need alerts:manage. Burn-rate incidents in list_alert_incidents and get_incident_timeline now point at their SLO instead of list_alert_rules. --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/lib/slos.ts | 187 +++++++ apps/ai/src/mcp/resources/instructions.ts | 1 + apps/ai/src/mcp/tool-descriptors.generated.ts | 5 + .../mcp/tools/__tests__/alert-tools.test.ts | 18 + .../src/mcp/tools/__tests__/slo-tools.test.ts | 298 +++++++++++ .../ai/src/mcp/tools/get-incident-timeline.ts | 4 +- apps/ai/src/mcp/tools/list-alert-incidents.ts | 6 +- apps/ai/src/mcp/tools/mutating.ts | 4 + apps/ai/src/mcp/tools/registry.ts | 2 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + apps/ai/src/mcp/tools/slos.ts | 474 ++++++++++++++++++ .../test/mcp/unimplemented-tool-services.ts | 2 + packages/domain/src/mcp-outputs/catalog.ts | 6 + packages/domain/src/mcp-outputs/index.ts | 1 + packages/domain/src/mcp-outputs/slo.ts | 98 ++++ 16 files changed, 1108 insertions(+), 2 deletions(-) create mode 100644 apps/ai/src/mcp/lib/slos.ts create mode 100644 apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts create mode 100644 apps/ai/src/mcp/tools/slos.ts create mode 100644 packages/domain/src/mcp-outputs/slo.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index bdcf068717..ba92b60bb7 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -29,6 +29,7 @@ import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService import { RailwayMetricsService } from "@maple/backend/services/integrations/RailwayMetricsService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" +import { SlosService } from "@maple/backend/services/slos/SlosService" import { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" @@ -292,6 +293,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( ScrapeTargetsService.layer, SecurityAuditStore.layer, SetupAuditService.layer, + SlosService.layer, StatusPageSnapshotBuilder.layer, StatusPagesService.layer, UptimeMonitorsService.layer, diff --git a/apps/ai/src/mcp/lib/slos.ts b/apps/ai/src/mcp/lib/slos.ts new file mode 100644 index 0000000000..26331d0e0f --- /dev/null +++ b/apps/ai/src/mcp/lib/slos.ts @@ -0,0 +1,187 @@ +/** Pieces the SLO MCP tools share: rows, burn summaries, SLI parsing, lookup and errors. */ +import { Effect, Option, Order, Schema, Array as Arr, String as Str } from "effect" +import type { SloConfigRow, SloStatusRow } from "@maple/domain/mcp-outputs" +import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { + SLO_LATENCY_THRESHOLDS_MS, + SloLatencyThresholdMs, + SloWindowDays, + type OrgId, +} from "@maple/domain/primitives" +import { SLO_BURN_TIERS, tierBreached, type SloBurnWindow, type SloSli } from "@maple/domain/slo" +import type { SloNotFoundError, SloPersistenceError, SloValidationError } from "@maple/domain/http" +import { SlosService, type SloRecord, type SloStatus } from "@maple/backend/services/slos/SlosService" +import { sloRuleName } from "@maple/backend/services/slos/slo-alert-rule" +import { McpInvalidInputError, McpQueryError } from "../tools/types" +import { doc, type NextCall } from "./tool-doc" + +export const SLI_KINDS = ["availability", "latency"] as const + +const LADDER = Arr.join(Arr.map(SLO_LATENCY_THRESHOLDS_MS, String), ", ") + +const iso = (ms: number) => new Date(ms).toISOString() + +/** `5m`, `30m`, `1h`, `6h`. */ +export const windowLabel = (minutes: number): string => + minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m` + +export const toSloConfigRow = (slo: SloRecord): typeof SloConfigRow.Type => ({ + id: slo.id, + name: slo.name, + description: slo.description, + serviceName: slo.serviceName, + environments: [...slo.environments], + sliKind: slo.sli._tag, + latencyThresholdMs: slo.sli._tag === "latency" ? slo.sli.thresholdMs : null, + targetPercent: slo.targetPercent, + windowDays: slo.windowDays, + alerting: { + enabled: slo.alerting.enabled, + alertRuleId: slo.alertRuleId, + criticalDestinationIds: [...slo.alerting.criticalDestinationIds], + warningDestinationIds: [...slo.alerting.warningDestinationIds], + }, + createdAt: iso(slo.createdAt), + updatedAt: iso(slo.updatedAt), +}) + +/** The highest alert tier the windows breach, critical before warning. */ +export const burningTier = (windows: ReadonlyArray): "critical" | "warning" | null => + Option.match( + Arr.findFirst(SLO_BURN_TIERS, (tier) => tierBreached(tier, windows)), + { onNone: () => null, onSome: (tier) => tier.severity }, + ) + +/** The fastest-burning window; the first one on a tie. */ +export const worstWindow = (windows: ReadonlyArray): SloBurnWindow | undefined => + Arr.sort(windows, Order.flip(Order.mapInput(Order.Number, (window: SloBurnWindow) => window.burnRate)))[0] + +export const toSloStatusRow = (status: SloStatus): typeof SloStatusRow.Type => ({ + asOf: iso(status.asOfMs), + total: status.total, + good: status.good, + sli: status.sli, + budgetRemaining: status.budgetRemaining, + burnWindows: Arr.map(status.burnWindows, (window) => ({ + window: windowLabel(window.windowMinutes), + windowMinutes: window.windowMinutes, + total: window.total, + good: window.good, + burnRate: window.burnRate, + })), + tiers: Arr.map(SLO_BURN_TIERS, (tier) => ({ + severity: tier.severity, + factor: tier.factor, + longWindow: windowLabel(tier.longWindowMinutes), + shortWindow: windowLabel(tier.shortWindowMinutes), + breached: tierBreached(tier, status.burnWindows), + })), +}) + +export const percent = (ratio: number | null, digits = 3): string => + ratio === null ? "-" : `${Number((ratio * 100).toFixed(digits))}%` + +export const burn = (rate: number | null): string => (rate === null ? "-" : `${rate.toFixed(2)}x`) + +export const sliSummary = (row: typeof SloConfigRow.Type): string => + row.sliKind === "latency" ? `latency under ${row.latencyThresholdMs ?? "?"} ms` : "availability" + +const invalid = (message: string, parameter: string) => new McpInvalidInputError({ message, parameter }) + +/** The SLI from flat tool parameters; `threshold_ms` only for latency, from the rollup ladder. */ +export const buildSli = ( + kind: (typeof SLI_KINDS)[number], + thresholdMs: number | undefined, +): Effect.Effect => { + if (kind === "availability") { + return thresholdMs === undefined + ? Effect.succeed({ _tag: "availability" }) + : Effect.fail(invalid("threshold_ms applies to a latency SLI, not availability.", "threshold_ms")) + } + if (thresholdMs === undefined) { + return Effect.fail(invalid(`A latency SLI needs threshold_ms, one of ${LADDER}.`, "threshold_ms")) + } + return Schema.decodeUnknownEffect(SloLatencyThresholdMs)(thresholdMs).pipe( + Effect.mapBoth({ + onSuccess: (ms): SloSli => ({ _tag: "latency", thresholdMs: ms }), + onFailure: () => + invalid( + `threshold_ms must be one of ${LADDER} (the latency buckets Maple counts spans in).`, + "threshold_ms", + ), + }), + ) +} + +export const decodeWindowDays = (raw: number | undefined) => + raw === undefined + ? Effect.undefined + : Schema.decodeUnknownEffect(SloWindowDays)(raw).pipe( + Effect.mapError(() => invalid("window_days must be 7, 28 or 30.", "window_days")), + ) + +type SloServiceError = SloNotFoundError | SloValidationError | SloPersistenceError + +/** The service's failures as the model reads them: bad input to fix, or an outage. */ +export const toMcpSloError = (tool: string) => (error: SloServiceError) => { + switch (error._tag) { + case "@maple/http/errors/SloValidationError": + return new McpInvalidInputError({ message: error.message }) + case "@maple/http/errors/SloNotFoundError": + return new McpInvalidInputError({ + message: "No such SLO. list_slos lists them.", + parameter: "slo", + }) + case "@maple/http/errors/SloPersistenceError": + return new McpQueryError({ + message: `${error._tag}: ${error.message}`, + pipeName: tool, + cause: error, + }) + } +} + +/** An SLO by its id, REST id (`slo_…`) or exact name; names are unique per org. */ +export const resolveSlo = Effect.fnUntraced(function* (tool: string, orgId: OrgId, ref: string) { + const service = yield* SlosService + const slos = yield* service.list(orgId).pipe(Effect.mapError(toMcpSloError(tool))) + const trimmed = Str.trim(ref) + const id = decodePublicId(PublicIdPrefixes.slo, trimmed) ?? trimmed + const found = Arr.findFirst(slos, (slo) => slo.id === id || slo.name === trimmed) + if (Option.isNone(found)) { + return yield* new McpInvalidInputError({ + message: `No SLO named "${ref}". list_slos lists them.`, + parameter: "slo", + }) + } + return found.value +}) + +const SLO_RULE_PREFIX = sloRuleName("") + +/** + * For burn-rate incidents: the SLOs behind them (their rules are hidden from list_alert_rules), + * named from the rule name, else the SLO list. + */ +export const sloIncidentNext = ( + incidents: ReadonlyArray<{ readonly signalType: string; readonly ruleName: string }>, +): ReadonlyArray => { + const burning = Arr.filter(incidents, (incident) => incident.signalType === "slo_burn_rate") + if (burning.length === 0) return [] + const names = Arr.dedupe( + Arr.flatMap(burning, (incident) => + Str.startsWith(SLO_RULE_PREFIX)(incident.ruleName) + ? [Str.slice(SLO_RULE_PREFIX.length)(incident.ruleName)] + : [], + ), + ) + return names.length === 0 + ? [doc.next("list_slos", {}, "the SLOs behind these burn-rate incidents")] + : Arr.map(names.slice(0, 3), (name) => + doc.next("inspect_slo", { slo: name }, `the burn windows of SLO "${name}"`), + ) +} + +/** Every incident is a burn-rate one, so list_alert_rules (which hides their rules) cannot help. */ +export const onlySloIncidents = (incidents: ReadonlyArray<{ readonly signalType: string }>): boolean => + incidents.length > 0 && Arr.every(incidents, (incident) => incident.signalType === "slo_burn_rate") diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 8c69604cab..70a9ec197b 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -118,6 +118,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - 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 - 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. - AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session (verdict and checks) -> get_agent_session_transcript (what it did step by step; \`failed_only\`, \`turn\`) -> inspect_span (one span in full) - End users (people the org's apps identified with identify() or a \`user.id\` attribute): list_users (most recently seen first; \`has_errors\`, \`search\`) -> inspect_user (sessions, MCP sessions, the issues they hit, failed requests) -> inspect_trace / error_detail / get_session_transcript diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index cc79e1ed5e..04715b9ff2 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -76,6 +76,11 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"create_uptime_monitor","title":"Create Uptime Monitor","description":"Start monitoring an endpoint. kind=http requests `url` and expects `expected_status` (default 2xx), optionally under `max_response_ms` and with `body_contains`; kind=tcp connects to `host`:`port`; kind=dns resolves `host` (`record_type`, optional `expected` answer); kind=heartbeat waits for your job to ping a URL every `heartbeat_interval_seconds` or on a `cron` schedule, plus `grace_seconds`. A monitor that goes down opens an alert incident and notifies `destination_ids`. Org admins and engineers only. Returns the monitor and, for a heartbeat, its ping URL and a curl line for the job.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the monitor, unique among the org's monitors and alert rules"},"kind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"description":"What the monitor checks"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations notified when the monitor goes down (list_alert_destinations lists them). Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Create Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true}}, {"name":"update_uptime_monitor","title":"Update Uptime Monitor","description":"Change an uptime monitor: pause it (enabled=false) or resume it (enabled=true, which restarts it from pending), or change its name, target, assertions, schedule, interval, service or destinations. Fields left out keep their value; a monitor cannot change kind. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id (list_uptime_monitors lists them)"},"enabled":{"type":"boolean","description":"false pauses the monitor, true resumes it"},"name":{"type":"string","description":"A new name"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"clear_service":{"type":"boolean","description":"true unlinks the monitor from its service; not with `service`"},"clear_max_response_ms":{"type":"boolean","description":"http: true removes the response time check; not with `max_response_ms`"},"clear_body_contains":{"type":"boolean","description":"http: true removes the body contains check; not with `body_contains`"},"clear_expected":{"type":"boolean","description":"dns: true removes the expected answer; not with `expected`"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Replaces the alert destinations notified when the monitor goes down; [] notifies none. Each is its UUID or its REST id (dest_\u2026)"}},"required":["monitor"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Update Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true}}, {"name":"delete_uptime_monitor","title":"Delete Uptime Monitor","description":"Delete an uptime monitor and its alert rule. Check spans already in the traces stay. Irreversible; requires confirm=true. To stop it for a while, update_uptime_monitor enabled=false instead. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["monitor","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete Uptime Monitor","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, + {"name":"list_slos","title":"List SLOs","description":"Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Only SLOs on this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnWindow":{"anyOf":[{"type":"string"},{"type":"null"}]},"burning":{"anyOf":[{"type":"string","enum":["critical","warning"]},{"type":"null"}]},"statusError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt","sli","budgetRemaining","worstBurnRate","worstBurnWindow","burning","statusError"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"serviceFilter":{"type":"string"}},"required":["slos","total"],"additionalProperties":true},"annotations":{"title":"List SLOs","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"inspect_slo","title":"Inspect SLO","description":"One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true},"status":{"type":"object","properties":{"asOf":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnWindows":{"type":"array","items":{"type":"object","properties":{"window":{"type":"string"},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["window","windowMinutes","total","good","burnRate"],"additionalProperties":true}},"tiers":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string","enum":["critical","warning"]},"factor":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"longWindow":{"type":"string"},"shortWindow":{"type":"string"},"breached":{"type":"boolean"}},"required":["severity","factor","longWindow","shortWindow","breached"],"additionalProperties":true}}},"required":["asOf","total","good","sli","budgetRemaining","burnWindows","tiers"],"additionalProperties":true}},"required":["slo","status"],"additionalProperties":true},"annotations":{"title":"Inspect SLO","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_slo","title":"Create SLO","description":"Define an SLO on one service's entry-point spans. sli=availability counts spans that are not errors as good; sli=latency counts spans faster than threshold_ms. target_percent is the good share to hold (50 to 99.999, e.g. 99.9) over window_days. Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) at 6x over 6h and 30m. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the SLO, unique among the org's SLOs"},"service":{"type":"string","description":"The service whose entry-point spans count (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"What makes a span good: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"Target share of good spans in percent, 50 to 99.999 (e.g. 99.9)"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","service","sli","target_percent"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Create SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_slo","title":"Update SLO","description":"Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new service (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"A new SLI: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"A new target share of good spans in percent, 50 to 99.999"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Update SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"delete_slo","title":"Delete SLO","description":"Delete an SLO and its burn-rate alert rule. Irreversible; requires confirm=true. To silence it instead, update_slo alerting_enabled=false. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["slo","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete SLO","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_ingest_rules","title":"List Ingest Rules","description":"The organization's ingest rules: what telemetry is dropped or redacted before Maple stores or bills it, in the order they run (drop rules first, then redact rules by position).","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"position":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"signals":{"type":"array","items":{"type":"string","enum":["traces","logs","metrics"]}},"conditions":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":true}},"action":{"$ref":"#/$defs/IngestRuleAction"},"summary":{"type":"string"}},"required":["id","name","enabled","position","signals","conditions","action","summary"],"additionalProperties":true}}},"required":["rules"],"additionalProperties":true,"$defs":{"IngestRuleDropAction":{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":true,"title":"Drop"},"IngestRuleRedactAction":{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":true,"title":"Redact"},"IngestRuleAction":{"anyOf":[{"$ref":"#/$defs/IngestRuleDropAction"},{"$ref":"#/$defs/IngestRuleRedactAction"}],"title":"Ingest rule action"}}},"annotations":{"title":"List Ingest Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"preview_ingest_rule","title":"Preview Ingest Rule","description":"Run an ingest rule (an existing one by `rule`, or a draft from signals/conditions/action) over recent stored traces or logs and report how many records it would drop or redact, with examples. Changes nothing. Recent data may already be redacted by existing rules; metrics are not previewed.","inputSchema":{"type":"object","properties":{"rule":{"type":"string","description":"An existing rule's name or id (list_ingest_rules lists them)"},"signals":{"type":"array","items":{"type":"string"},"description":"Draft: signals, traces, logs, metrics"},"conditions":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":false}},"description":"Conditions that must all match, e.g. [{\"field\":\"severity\",\"op\":\"lt\",\"value\":\"info\"}] or [{\"field\":\"attribute\",\"key\":\"http.route\",\"op\":\"in\",\"values\":[\"/health\"]}]. Fields: service_name, name, attribute, resource_attribute, body, severity, span_kind, span_status. Ops: eq, neq, in, not_in, prefix, contains, regex, exists, not_exists, lt, gte (severity). Empty matches every record (redact rules only)."},"action":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":false,"title":"Drop"},{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":false,"title":"Redact"}],"title":"Ingest rule action"},"description":"Draft: What to do: {\"type\":\"drop\",\"keep_percent\":0} or {\"type\":\"redact\",\"keys\":[\"*password*\"],\"detectors\":[\"email\",\"credit_card\",\"jwt\",\"api_token\",\"ipv4\",\"ipv6\",\"phone\",\"iban\"],\"patterns\":[\"ORD-[0-9]+\"],\"scopes\":[\"attributes\",\"resource\",\"events\",\"body\"],\"method\":\"mask\"|\"hash\"|\"remove\"}."},"signal":{"type":"string","enum":["traces","logs"],"description":"Which signal to sample; defaults to the rule's first of traces/logs"},"service":{"type":"string","description":"Only sample this service"},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 1 hour before end_time. Covers at most 1 day; a wider window is narrowed to the 1 day before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"limit":{"type":"number","description":"Max sample records to return (default 200, max 500)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"signal":{"type":"string","enum":["traces","logs","metrics"]},"sampled":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"dropped":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedRecords":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedValues":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"examples":{"type":"array","items":{"type":"object","properties":{"service":{"type":"string"},"label":{"type":"string"},"outcome":{"type":"string","enum":["dropped","redacted"]},"changes":{"type":"array","items":{"type":"string"}}},"required":["service","label","outcome","changes"],"additionalProperties":true}},"problems":{"type":"array","items":{"type":"string"}},"notes":{"type":"array","items":{"type":"string"}}},"required":["signal","sampled","dropped","redactedRecords","redactedValues","examples","problems"],"additionalProperties":true},"annotations":{"title":"Preview Ingest Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_ingest_rule","title":"Create Ingest Rule","description":"Add a rule that drops or redacts telemetry before Maple stores or bills it. Dropped data is never billed. Drop rules need at least one condition. Preview with preview_ingest_rule first. Org admins and engineers only. Applies to new data within 30 seconds.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the rule"},"signals":{"type":"array","items":{"type":"string"},"description":"Signals the rule applies to: traces, logs, metrics"},"conditions":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":false}},"description":"Conditions that must all match, e.g. [{\"field\":\"severity\",\"op\":\"lt\",\"value\":\"info\"}] or [{\"field\":\"attribute\",\"key\":\"http.route\",\"op\":\"in\",\"values\":[\"/health\"]}]. Fields: service_name, name, attribute, resource_attribute, body, severity, span_kind, span_status. Ops: eq, neq, in, not_in, prefix, contains, regex, exists, not_exists, lt, gte (severity). Empty matches every record (redact rules only)."},"action":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":false,"title":"Drop"},{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":false,"title":"Redact"}],"title":"Ingest rule action"},"description":"What to do: {\"type\":\"drop\",\"keep_percent\":0} or {\"type\":\"redact\",\"keys\":[\"*password*\"],\"detectors\":[\"email\",\"credit_card\",\"jwt\",\"api_token\",\"ipv4\",\"ipv6\",\"phone\",\"iban\"],\"patterns\":[\"ORD-[0-9]+\"],\"scopes\":[\"attributes\",\"resource\",\"events\",\"body\"],\"method\":\"mask\"|\"hash\"|\"remove\"}."},"enabled":{"type":"boolean","description":"Start disabled with false; default true"},"position":{"type":"number","description":"Run order among redact rules; default after the last rule"}},"required":["name","signals","action"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"position":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"signals":{"type":"array","items":{"type":"string","enum":["traces","logs","metrics"]}},"conditions":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":true}},"action":{"$ref":"#/$defs/IngestRuleAction"},"summary":{"type":"string"}},"required":["id","name","enabled","position","signals","conditions","action","summary"],"additionalProperties":true}},"required":["action","rule"],"additionalProperties":true,"$defs":{"IngestRuleDropAction":{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":true,"title":"Drop"},"IngestRuleRedactAction":{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":true,"title":"Redact"},"IngestRuleAction":{"anyOf":[{"$ref":"#/$defs/IngestRuleDropAction"},{"$ref":"#/$defs/IngestRuleRedactAction"}],"title":"Ingest rule action"}}},"annotations":{"title":"Create Ingest Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index a0b526b6ae..7dece5d9a8 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -451,6 +451,24 @@ describe("alert tools", () => { expect(text(await ok("get_incident_timeline", {}))).not.toContain(hint) }) + it("both incident tools send burn-rate incidents to their SLO, not to list_alert_rules", async () => { + const burn = incident({ + id: "44444444-3333-4333-8444-555555555555", + signalType: "slo_burn_rate", + ruleName: "SLO burn: Checkout availability", + groupKey: "__total__", + status: "resolved", + resolvedAt: NOW, + }) + const inspect = '`inspect_slo slo="Checkout availability"`' + for (const tool of ["list_alert_incidents", "get_incident_timeline"]) { + const onlySlo = text(await ok(tool, { status: "resolved" }, { extraIncidents: [burn] })) + expect(onlySlo, tool).toContain(inspect) + expect(onlySlo, tool).not.toContain("`list_alert_rules`") + expect(text(await ok(tool, {})), tool).not.toContain("inspect_slo") + } + }) + it("get_incident_timeline filters by rule and rejects a malformed rule_id", async () => { const seen: Seen = {} const result = await ok("get_incident_timeline", { rule_id: RULE_ID, limit: 1 }, seen) diff --git a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts new file mode 100644 index 0000000000..4128b8fddc --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts @@ -0,0 +1,298 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" +import { + DeleteSloOutput, + InspectSloOutput, + ListSlosOutput, + SloMutationOutput, +} from "@maple/domain/mcp-outputs" +import { RoleName } from "@maple/domain/primitives" +import { formatWarehouseDateTime } from "@maple/query-engine" +import type { McpToolResult } from "../types" +import { makeEvalRuntime, markdown, runToolDirect, type EvalRuntime } from "../../__evals__/eval-runtime" +import { installFakeWarehouse, restoreWarehouse, type FixtureRule } from "../../__evals__/fake-warehouse" + +/** Bad spans per 1000 in every minute of the burn read, by service. */ +const BAD_PER_MILLE = { checkout: 20, search: 0 } as const + +const badOf = (sql: string): number => + Option.match( + Arr.findFirst(Rec.keys(BAD_PER_MILLE), (service) => Str.includes(`ServiceName = '${service}'`)(sql)), + { onNone: () => 0, onSome: (service) => BAD_PER_MILLE[service] }, + ) + +const fixtures: FixtureRule[] = [ + // The burn read: 400 minute buckets of uniform traffic ending just before now. + { + match: (sql) => + Str.includes("FROM service_overview_spans")(sql) && Str.includes("INTERVAL 60 SECOND")(sql), + rows: (sql) => { + const minute = Math.floor(Date.now() / 60_000) * 60_000 + return Arr.makeBy(400, (index) => ({ + bucket: formatWarehouseDateTime(minute - (index + 1) * 60_000), + total: 1000, + good: 1000 - badOf(sql), + })) + }, + }, + // The window totals: half of a 99.9% budget spent on checkout, none on search. + { + match: (sql) => Str.includes("FROM service_overview_spans")(sql), + rows: (sql) => [{ total: 1_000_000, good: badOf(sql) > 0 ? 999_500 : 1_000_000 }], + }, +] + +let member: EvalRuntime +let admin: EvalRuntime +beforeAll(() => { + installFakeWarehouse(fixtures) + member = makeEvalRuntime() + admin = { ...member, tenant: { ...member.tenant, roles: [Schema.decodeSync(RoleName)("org:admin")] } } +}) +afterAll(async () => { + restoreWarehouse() + await member.dispose() +}) + +const decode = >(schema: S, result: McpToolResult) => + Schema.decodeSync(schema)(result.structuredContent) + +const DENIED = "only org admins and engineers" + +describe("create_slo", () => { + it("is refused for a member who is not an admin or engineer", async () => { + const result = await runToolDirect(member, "create_slo", { + name: "Member SLO", + service: "checkout", + sli: "availability", + target_percent: 99.9, + }) + expect(result.isError).toBe(true) + expect(markdown(result)).toContain(DENIED) + }) + + it("creates an availability SLO with its burn-rate alert on, and points at inspect_slo", async () => { + const result = await runToolDirect(admin, "create_slo", { + name: "Checkout availability", + service_name: "checkout", + sli: "availability", + target_percent: 99.9, + environments: ["production"], + }) + const output = decode(SloMutationOutput, result) + expect(output).toMatchObject({ + action: "created", + slo: { + serviceName: "checkout", + environments: ["production"], + sliKind: "availability", + latencyThresholdMs: null, + targetPercent: 99.9, + windowDays: 30, + alerting: { enabled: true, criticalDestinationIds: [], warningDestinationIds: [] }, + }, + }) + expect(output.slo.alerting.alertRuleId).not.toBeNull() + expect(markdown(result)).toContain('inspect_slo slo="Checkout availability"') + }) + + it("creates a latency SLO on a threshold from the ladder", async () => { + const output = decode( + SloMutationOutput, + await runToolDirect(admin, "create_slo", { + name: "Search latency", + service: "search", + sli: "latency", + threshold_ms: "500", + target_percent: 99, + window_days: 7, + }), + ) + expect(output.slo).toMatchObject({ sliKind: "latency", latencyThresholdMs: 500, windowDays: 7 }) + }) + + it("names the latency threshold it needs, and the window days it accepts", async () => { + const missing = await runToolDirect(admin, "create_slo", { + name: "No threshold", + service: "search", + sli: "latency", + target_percent: 99, + }) + expect(missing.isError).toBe(true) + expect(markdown(missing)).toContain("needs threshold_ms") + + const offLadder = await runToolDirect(admin, "create_slo", { + name: "Odd threshold", + service: "search", + sli: "latency", + threshold_ms: 300, + target_percent: 99, + }) + expect(offLadder.isError).toBe(true) + expect(markdown(offLadder)).toContain("250, 500") + + const badWindow = await runToolDirect(admin, "create_slo", { + name: "Odd window", + service: "search", + sli: "availability", + target_percent: 99, + window_days: 14, + }) + expect(badWindow.isError).toBe(true) + expect(markdown(badWindow)).toContain("window_days must be 7, 28 or 30") + }) + + it("refuses a target outside 50 to 99.999 and an unknown destination as bad input", async () => { + const target = await runToolDirect(admin, "create_slo", { + name: "Too strict", + service: "checkout", + sli: "availability", + target_percent: 100, + }) + expect(target.isError).toBe(true) + expect(markdown(target)).toContain("between 50 and 99.999") + + const destination = await runToolDirect(admin, "create_slo", { + name: "Ghost pager", + service: "checkout", + sli: "availability", + target_percent: 99, + critical_destination_ids: ["5f0c8a52-1111-4222-8333-444455556666"], + }) + expect(destination.isError).toBe(true) + expect(markdown(destination)).toContain("Unknown alert destination") + }) +}) + +describe("list_slos", () => { + it("puts the burning SLO first with its SLI, budget left and worst burn", async () => { + const result = await runToolDirect(member, "list_slos", {}) + const output = decode(ListSlosOutput, result) + expect(Arr.map(output.slos, (row) => row.name)).toEqual(["Checkout availability", "Search latency"]) + const [checkout, search] = output.slos + expect(checkout).toMatchObject({ + sli: 0.9995, + burning: "critical", + worstBurnWindow: "5m", + statusError: null, + }) + expect(checkout?.budgetRemaining).toBeCloseTo(0.5) + expect(checkout?.worstBurnRate).toBeCloseTo(20) + expect(search).toMatchObject({ burning: null, worstBurnRate: 0, budgetRemaining: 1 }) + expect(markdown(result)).toContain('inspect_slo slo="Checkout availability"') + }) + + it("filters by service", async () => { + const output = decode(ListSlosOutput, await runToolDirect(member, "list_slos", { service: "search" })) + expect(Arr.map(output.slos, (row) => row.name)).toEqual(["Search latency"]) + expect(output.serviceFilter).toBe("search") + }) +}) + +describe("inspect_slo", () => { + it("shows each burn window, the breaching tiers and the rule's incidents as the next call", async () => { + const result = await runToolDirect(member, "inspect_slo", { slo: "Checkout availability" }) + const output = decode(InspectSloOutput, result) + expect(Arr.map(output.status.burnWindows, (window) => window.window)).toEqual([ + "5m", + "30m", + "1h", + "6h", + ]) + for (const window of output.status.burnWindows) { + expect(window.total).toBe(window.windowMinutes * 1000) + expect(window.burnRate).toBeCloseTo(20) + } + expect(output.status.tiers).toMatchObject([ + { severity: "critical", factor: 14.4, longWindow: "1h", shortWindow: "5m", breached: true }, + { severity: "warning", factor: 6, longWindow: "6h", shortWindow: "30m", breached: true }, + ]) + expect(output.status).toMatchObject({ total: 1_000_000, good: 999_500 }) + expect(markdown(result)).toContain("The critical tier is breaching") + expect(markdown(result)).toContain( + `get_incident_timeline rule_id="${output.slo.alerting.alertRuleId ?? "missing"}"`, + ) + }) + + it("finds an SLO by its id and says how to list them when nothing matches", async () => { + const listed = decode(ListSlosOutput, await runToolDirect(member, "list_slos", { service: "search" })) + const byId = decode( + InspectSloOutput, + await runToolDirect(member, "inspect_slo", { slo: listed.slos[0]?.id ?? "missing" }), + ) + expect(byId.slo.name).toBe("Search latency") + expect(Arr.every(byId.status.tiers, (tier) => !tier.breached)).toBe(true) + + const missing = await runToolDirect(member, "inspect_slo", { slo: "Nope" }) + expect(missing.isError).toBe(true) + expect(markdown(missing)).toContain("list_slos") + }) +}) + +describe("update_slo", () => { + it("is refused for a member", async () => { + const result = await runToolDirect(member, "update_slo", { + slo: "Checkout availability", + target_percent: 99, + }) + expect(result.isError).toBe(true) + expect(markdown(result)).toContain(DENIED) + }) + + it("silences the burn alert and changes the target, keeping the other fields", async () => { + const result = await runToolDirect(admin, "update_slo", { + slo: "Checkout availability", + alerting_enabled: false, + target_percent: 99.5, + }) + const output = decode(SloMutationOutput, result) + expect(output.slo).toMatchObject({ + targetPercent: 99.5, + serviceName: "checkout", + environments: ["production"], + alerting: { enabled: false }, + }) + expect(markdown(result)).toContain("Burn-rate alerts are off") + }) + + it("changes a latency threshold alone and refuses one on an availability SLI", async () => { + const latency = decode( + SloMutationOutput, + await runToolDirect(admin, "update_slo", { slo: "Search latency", threshold_ms: 1000 }), + ) + expect(latency.slo).toMatchObject({ sliKind: "latency", latencyThresholdMs: 1000 }) + + const availability = await runToolDirect(admin, "update_slo", { + slo: "Checkout availability", + threshold_ms: 1000, + }) + expect(availability.isError).toBe(true) + expect(markdown(availability)).toContain("threshold_ms applies to a latency SLI") + }) +}) + +describe("delete_slo", () => { + it("refuses a member outright, without asking them to confirm first", async () => { + const result = await runToolDirect(member, "delete_slo", { slo: "Search latency", confirm: false }) + expect(result.isError).toBe(true) + expect(markdown(result)).toContain(DENIED) + expect(markdown(result)).not.toContain("confirm=true") + }) + + it("asks for confirmation, then deletes so the SLO no longer lists", async () => { + const unconfirmed = await runToolDirect(admin, "delete_slo", { + slo: "Search latency", + confirm: false, + }) + expect(unconfirmed.isError).toBe(true) + expect(markdown(unconfirmed)).toContain("confirm=true") + + const deleted = decode( + DeleteSloOutput, + await runToolDirect(admin, "delete_slo", { slo: "Search latency", confirm: true }), + ) + expect(deleted).toMatchObject({ deleted: true, name: "Search latency" }) + const listed = decode(ListSlosOutput, await runToolDirect(member, "list_slos", {})) + expect(Arr.map(listed.slos, (row) => row.name)).toEqual(["Checkout availability"]) + }) +}) diff --git a/apps/ai/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts index 3b89f78735..a032f16b22 100644 --- a/apps/ai/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -5,6 +5,7 @@ import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition, parseRuleId } from "../lib/alert-rules" import * as P from "../lib/params" +import { onlySloIncidents, sloIncidentNext } from "../lib/slos" import { doc, type DocBlock } from "../lib/tool-doc" const MAX_LIMIT = 200 @@ -194,9 +195,10 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) { ...(open.length > 0 ? [doc.next("find_errors", {}, "search for recent errors related to open incidents")] : []), - ...(open.length === 0 + ...(open.length === 0 && !onlySloIncidents(output.incidents) ? [doc.next("list_alert_rules", {}, "review alert configuration")] : []), + ...sloIncidentNext(output.incidents), ...(Arr.some(output.incidents, (i) => i.signalType === "uptime") ? [doc.next("list_uptime_monitors", {}, "the uptime monitors behind these incidents")] : []), diff --git a/apps/ai/src/mcp/tools/list-alert-incidents.ts b/apps/ai/src/mcp/tools/list-alert-incidents.ts index 55e689c341..fc5ab1c6bc 100644 --- a/apps/ai/src/mcp/tools/list-alert-incidents.ts +++ b/apps/ai/src/mcp/tools/list-alert-incidents.ts @@ -6,6 +6,7 @@ import { CurrentMcpTenant } from "../lib/query-warehouse" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition } from "../lib/alert-rules" import * as P from "../lib/params" +import { onlySloIncidents, sloIncidentNext } from "../lib/slos" import { doc } from "../lib/tool-doc" import { parseWarehouseDateTime } from "@maple/query-engine" @@ -240,7 +241,10 @@ export function registerListAlertIncidentsTool(server: McpToolRegistrar) { "inspect this alert group", ), ) - : [doc.next("list_alert_rules", {}, "review alert configuration")]), + : onlySloIncidents(output.incidents) + ? [] + : [doc.next("list_alert_rules", {}, "review alert configuration")]), + ...sloIncidentNext(output.incidents), // Uptime incidents come from monitors, whose rules list_alert_rules hides. ...(Arr.some(output.incidents, (i) => i.signalType === "uptime") ? [doc.next("list_uptime_monitors", {}, "the uptime monitors behind these incidents")] diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 8f50153e5d..c47d11d3d2 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -36,6 +36,10 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_scrape_target", "update_scrape_target", "delete_scrape_target", + // SLOs + "create_slo", + "update_slo", + "delete_slo", // uptime monitors "create_uptime_monitor", "update_uptime_monitor", diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 9cba7fac8c..2e6aa95f62 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -31,6 +31,7 @@ import { registerCiRunsTool } from "./ci-runs" import { registerCompareCommitsTool } from "./compare-commits" import { registerIntegrationWriteTools } from "./integration-writes" import { registerUptimeMonitorTools } from "./uptime-monitors" +import { registerSloTools } from "./slos" import { registerIngestRuleTools } from "./ingest-rules" import { registerStatusPageTools } from "./status-pages" import { registerGetInstrumentationRecommendationsTool } from "./get-instrumentation-recommendations" @@ -464,6 +465,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerCompareCommitsTool(registrar) registerIntegrationWriteTools(registrar) registerUptimeMonitorTools(registrar) + registerSloTools(registrar) registerIngestRuleTools(registrar) registerStatusPageTools(registrar) registerSourceCodeTools(registrar) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index 9cacd010ac..6e69e24e3f 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -19,6 +19,7 @@ import type { ScrapeTargetsService } from "@maple/backend/services/integrations/ import type { VcsCommitService } from "@maple/backend/services/integrations/vcs/VcsCommitService" import type { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" import type { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" +import type { SlosService } from "@maple/backend/services/slos/SlosService" import type { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" import type { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import type { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" @@ -65,6 +66,7 @@ export type McpToolRuntimeRequirements = | ScrapeTargetsService | SecurityAuditStore | SetupAuditService + | SlosService | StatusPageSnapshotBuilder | StatusPagesService | OrganizationFeatureFlagsService diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts new file mode 100644 index 0000000000..3efc553cea --- /dev/null +++ b/apps/ai/src/mcp/tools/slos.ts @@ -0,0 +1,474 @@ +/** + * SLOs: a target share of good entry-point spans for one service over a rolling window, with a + * hidden burn-rate alert rule. Anyone may read them; writes need `alerts:manage`, like the REST routes. + */ +import { Effect, Order, Schema, Array as Arr } from "effect" +import { + DeleteSloOutput, + InspectSloOutput, + ListSlosOutput, + SloMutationOutput, + type SloConfigRow, + type SloListRow, +} from "@maple/domain/mcp-outputs" +import type { AlertDestinationId } from "@maple/domain/primitives" +import * as Policy from "@maple/domain/policy" +import { SlosService } from "@maple/backend/services/slos/SlosService" +import { McpInvalidInputError, McpUnavailableError, type McpToolRegistrar } from "./types" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { warehouseReadToMcpHandlers } from "../lib/map-warehouse-error" +import * as P from "../lib/params" +import { doc, type NextCall } from "../lib/tool-doc" +import { + buildSli, + burn, + burningTier, + decodeWindowDays, + percent, + resolveSlo, + SLI_KINDS, + sliSummary, + toMcpSloError, + toSloConfigRow, + toSloStatusRow, + windowLabel, + worstWindow, +} from "../lib/slos" + +const canManage = (tool: string) => + Effect.gen(function* () { + const tenant = yield* CurrentMcpTenant + yield* Policy.check( + tenant.roles, + "alerts:manage", + () => + new McpUnavailableError({ + message: `${tool} changes the organization's SLOs and their burn-rate alerts, which only org admins and engineers may do. Ask an admin or engineer, or change it in Maple.`, + capability: "org_admin", + }), + ) + return tenant + }) + +const decodeDestinations = (raw: ReadonlyArray | undefined, parameter: string) => + raw === undefined + ? Effect.undefined + : Effect.forEach(raw, P.decodeResourceId("alertDestination", parameter)) + +/** The alerting fields a caller sent; undefined when none. */ +const alertingInput = Effect.fnUntraced(function* (params: { + readonly alerting_enabled?: boolean | undefined + readonly critical_destination_ids?: ReadonlyArray | undefined + readonly warning_destination_ids?: ReadonlyArray | undefined +}) { + const critical: ReadonlyArray | undefined = yield* decodeDestinations( + params.critical_destination_ids, + "critical_destination_ids", + ) + const warning: ReadonlyArray | undefined = yield* decodeDestinations( + params.warning_destination_ids, + "warning_destination_ids", + ) + if (params.alerting_enabled === undefined && critical === undefined && warning === undefined) { + return undefined + } + return { + ...(params.alerting_enabled === undefined ? undefined : { enabled: params.alerting_enabled }), + ...(critical === undefined ? undefined : { criticalDestinationIds: critical }), + ...(warning === undefined ? undefined : { warningDestinationIds: warning }), + } +}) + +const BURN_TIER_TEXT = + "Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) at 6x over 6h and 30m." + +const destinationsCell = (ids: ReadonlyArray) => (ids.length === 0 ? "none" : Arr.join(ids, ", ")) + +const configFields = (row: typeof SloConfigRow.Type) => + doc.fields([ + ["Name", row.name], + ["Id", row.id], + ["Service", row.serviceName], + ["Environments", row.environments.length === 0 ? "all" : Arr.join(row.environments, ", ")], + ["SLI", sliSummary(row)], + ["Target", `${row.targetPercent}% over ${row.windowDays} days`], + ["Description", row.description ?? undefined], + ["Burn alerts", row.alerting.enabled ? "on" : "off"], + ["Critical notifies", destinationsCell(row.alerting.criticalDestinationIds)], + ["Warning notifies", destinationsCell(row.alerting.warningDestinationIds)], + ["Alert rule", row.alerting.alertRuleId ?? "none"], + ]) + +const ruleNext = (row: typeof SloConfigRow.Type): ReadonlyArray => + row.alerting.alertRuleId === null + ? [] + : [ + doc.next( + "get_incident_timeline", + { rule_id: row.alerting.alertRuleId }, + `the burn-rate incidents of "${row.name}"`, + ), + ] + +const renderMutation = (title: string, row: typeof SloConfigRow.Type) => ({ + title, + notices: row.alerting.enabled + ? [] + : ["Burn-rate alerts are off: nothing pages. update_slo alerting_enabled=true turns them on."], + blocks: [configFields(row), doc.text(BURN_TIER_TEXT)], + next: [doc.next("inspect_slo", { slo: row.name }, `where "${row.name}" stands now`)], +}) + +const BURNING_ORDER = { critical: 0, warning: 1 } as const +const rank = (row: typeof SloListRow.Type) => (row.burning === null ? 2 : BURNING_ORDER[row.burning]) +/** Breaching tiers first (critical, then warning), then the least budget left. */ +const LIST_ORDER = Order.combine( + Order.mapInput(Order.Number, rank), + Order.mapInput( + Order.Number, + (row: typeof SloListRow.Type) => row.budgetRemaining ?? Number.POSITIVE_INFINITY, + ), +) + +const sloParameters = { + description: P.optionalText("What the SLO promises, for people reading it"), + environments: P.optionalList( + "Deployment environments whose spans count (e.g. production). Omit or [] for every environment", + ), + threshold_ms: P.optionalNumber( + "latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000", + ), + window_days: P.optionalNumber("Rolling window the error budget covers: 7, 28 or 30 days (default 30)"), + alerting_enabled: P.optionalFlag("Whether the SLO's burn-rate alert fires (default true)"), + critical_destination_ids: P.optionalResourceIdList( + "alertDestination", + "Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them", + ), + warning_destination_ids: P.optionalResourceIdList( + "alertDestination", + "Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too", + ), +} as const + +export function registerSloTools(server: McpToolRegistrar) { + server.define({ + name: "list_slos", + title: "List SLOs", + description: + "Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.", + parameters: Schema.Struct({ + service: P.service("Only SLOs on this service (exact `service.name`)"), + }), + aliases: P.SERVICE_ALIASES, + output: ListSlosOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Checking SLOs"], + handler: Effect.fn("McpTool.listSlos")(function* (params) { + const tenant = yield* CurrentMcpTenant + const service = yield* SlosService + const slos = yield* service.list(tenant.orgId).pipe(Effect.mapError(toMcpSloError("list_slos"))) + const shown = Arr.filter( + slos, + (slo) => params.service === undefined || slo.serviceName === params.service, + ) + // One SLO's failed read leaves its status empty instead of failing the whole list. + const rows = yield* Effect.forEach( + shown, + (slo) => + service.status(tenant, slo.id).pipe( + Effect.match({ + onFailure: (error): typeof SloListRow.Type => ({ + ...toSloConfigRow(slo), + sli: null, + budgetRemaining: null, + worstBurnRate: null, + worstBurnWindow: null, + burning: null, + statusError: error.message, + }), + onSuccess: (status): typeof SloListRow.Type => { + const worst = worstWindow(status.burnWindows) + return { + ...toSloConfigRow(slo), + sli: status.sli, + budgetRemaining: status.budgetRemaining, + worstBurnRate: worst?.burnRate ?? null, + worstBurnWindow: + worst === undefined ? null : windowLabel(worst.windowMinutes), + burning: burningTier(status.burnWindows), + statusError: null, + } + }, + }), + ), + { concurrency: 4 }, + ) + const sorted = Arr.sort(rows, LIST_ORDER) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "result.rowCount": sorted.length }) + return { + slos: sorted, + total: sorted.length, + ...(params.service === undefined ? undefined : { serviceFilter: params.service }), + } + }), + render: (output) => ({ + title: "SLOs", + scope: [["Service", output.serviceFilter]], + notices: Arr.map( + Arr.filter(output.slos, (row) => row.statusError !== null), + (row) => `Could not read the status of "${row.name}": ${row.statusError ?? ""}`, + ), + ...(output.slos.length === 0 + ? { + empty: { + message: + output.serviceFilter === undefined + ? "This organization has no SLOs." + : "No SLO is set on this service.", + hints: ["create_slo adds one (org admins and engineers only)."], + }, + } + : undefined), + blocks: + output.slos.length === 0 + ? [] + : [ + doc.table( + [ + "Name", + "Service", + "SLI", + "Target", + "Current", + "Budget left", + "Worst burn", + "Burning", + ], + Arr.map(output.slos, (row) => [ + row.name, + row.serviceName, + sliSummary(row), + `${row.targetPercent}% / ${row.windowDays}d`, + percent(row.sli), + percent(row.budgetRemaining, 1), + row.worstBurnWindow === null + ? "-" + : `${burn(row.worstBurnRate)} (${row.worstBurnWindow})`, + row.burning ?? "-", + ]), + ), + ], + next: Arr.map(Arr.filter(output.slos, (row) => row.burning !== null).slice(0, 3), (row) => + doc.next("inspect_slo", { slo: row.name }, `the burn windows of "${row.name}"`), + ), + }), + }) + + server.define({ + name: "inspect_slo", + title: "Inspect SLO", + description: + "One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).", + parameters: Schema.Struct({ + slo: P.text("The SLO's name or id (list_slos lists them)"), + }), + output: InspectSloOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Inspecting an SLO"], + handler: Effect.fn("McpTool.inspectSlo")(function* (params) { + const tenant = yield* CurrentMcpTenant + const service = yield* SlosService + const slo = yield* resolveSlo("inspect_slo", tenant.orgId, params.slo) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.slo.id": slo.id }) + const status = yield* service.status(tenant, slo.id).pipe( + Effect.catchTags({ + "@maple/http/errors/SloNotFoundError": (error) => + Effect.fail(toMcpSloError("inspect_slo")(error)), + "@maple/http/errors/SloPersistenceError": (error) => + Effect.fail(toMcpSloError("inspect_slo")(error)), + }), + Effect.catchTags(warehouseReadToMcpHandlers("inspect_slo")), + ) + return { slo: toSloConfigRow(slo), status: toSloStatusRow(status) } + }), + render: (output) => { + const breached = Arr.filter(output.status.tiers, (tier) => tier.breached) + return { + title: `SLO: ${output.slo.name}`, + notices: [ + ...Arr.map( + breached, + (tier) => + `The ${tier.severity} tier is breaching: ${tier.longWindow} and ${tier.shortWindow} both burn at ${tier.factor}x or more.`, + ), + ...(output.status.budgetRemaining < 0 ? ["The error budget is spent."] : []), + ...(output.status.total === 0 + ? [`No entry-point spans from ${output.slo.serviceName} in the window yet.`] + : []), + ], + blocks: [ + configFields(output.slo), + doc.fields([ + ["As of", output.status.asOf], + ["Events", `${output.status.good} good of ${output.status.total}`], + ["SLI", percent(output.status.sli)], + ["Budget left", percent(output.status.budgetRemaining, 1)], + ]), + doc.table( + ["Window", "Events", "Good", "Burn rate"], + Arr.map(output.status.burnWindows, (window) => [ + window.window, + String(window.total), + String(window.good), + burn(window.burnRate), + ]), + ), + doc.table( + ["Tier", "Fires at", "Windows", "Breaching"], + Arr.map(output.status.tiers, (tier) => [ + tier.severity, + `${tier.factor}x`, + `${tier.longWindow} and ${tier.shortWindow}`, + tier.breached ? "yes" : "no", + ]), + ), + ], + next: [ + ...ruleNext(output.slo), + doc.next( + "diagnose_service", + { service: output.slo.serviceName }, + `what is wrong with ${output.slo.serviceName}`, + ), + ], + } + }, + }) + + server.define({ + name: "create_slo", + title: "Create SLO", + description: `Define an SLO on one service's entry-point spans. sli=availability counts spans that are not errors as good; sli=latency counts spans faster than threshold_ms. target_percent is the good share to hold (50 to 99.999, e.g. 99.9) over window_days. ${BURN_TIER_TEXT} Org admins and engineers only.`, + parameters: Schema.Struct({ + name: P.text("A label for the SLO, unique among the org's SLOs"), + service: P.text("The service whose entry-point spans count (exact `service.name`)"), + sli: P.oneOf(SLI_KINDS, "What makes a span good: not an error, or faster than threshold_ms"), + target_percent: P.number("Target share of good spans in percent, 50 to 99.999 (e.g. 99.9)"), + ...sloParameters, + }), + aliases: P.SERVICE_ALIASES, + output: SloMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: false }, + phrases: ["Adding an SLO"], + handler: Effect.fn("McpTool.createSlo")(function* (params) { + const tenant = yield* canManage("create_slo") + const service = yield* SlosService + const sli = yield* buildSli(params.sli, params.threshold_ms) + const windowDays = yield* decodeWindowDays(params.window_days) + const alerting = yield* alertingInput(params) + const created = yield* service + .create(tenant.orgId, { + name: params.name, + serviceName: params.service, + sli, + targetPercent: params.target_percent, + ...(params.description === undefined ? undefined : { description: params.description }), + ...(params.environments === undefined + ? undefined + : { environments: params.environments }), + ...(windowDays === undefined ? undefined : { windowDays }), + ...(alerting === undefined ? undefined : { alerting }), + createdBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpSloError("create_slo"))) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.slo.id": created.id }) + return { action: "created" as const, slo: toSloConfigRow(created) } + }), + render: (output) => renderMutation("SLO created", output.slo), + }) + + server.define({ + name: "update_slo", + title: "Update SLO", + description: + "Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.", + parameters: Schema.Struct({ + slo: P.text("The SLO's name or id (list_slos lists them)"), + name: P.optionalText("A new name"), + service: P.service("A new service (exact `service.name`)"), + sli: P.optionalOneOf(SLI_KINDS, "A new SLI: not an error, or faster than threshold_ms"), + target_percent: P.optionalNumber("A new target share of good spans in percent, 50 to 99.999"), + ...sloParameters, + }), + aliases: P.SERVICE_ALIASES, + output: SloMutationOutput, + hints: { readOnly: false, destructive: false, idempotent: true }, + phrases: ["Updating an SLO"], + handler: Effect.fn("McpTool.updateSlo")(function* (params) { + const tenant = yield* canManage("update_slo") + const service = yield* SlosService + const current = yield* resolveSlo("update_slo", tenant.orgId, params.slo) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.slo.id": current.id }) + const sliKind = params.sli ?? (params.threshold_ms === undefined ? undefined : current.sli._tag) + const sli = sliKind === undefined ? undefined : yield* buildSli(sliKind, params.threshold_ms) + const windowDays = yield* decodeWindowDays(params.window_days) + const alerting = yield* alertingInput(params) + const updated = yield* service + .update(tenant.orgId, current.id, { + ...(params.name === undefined ? undefined : { name: params.name }), + // An empty description removes it. + ...(params.description === undefined + ? undefined + : { description: params.description === "" ? null : params.description }), + ...(params.service === undefined ? undefined : { serviceName: params.service }), + ...(params.environments === undefined + ? undefined + : { environments: params.environments }), + ...(sli === undefined ? undefined : { sli }), + ...(params.target_percent === undefined + ? undefined + : { targetPercent: params.target_percent }), + ...(windowDays === undefined ? undefined : { windowDays }), + ...(alerting === undefined ? undefined : { alerting }), + updatedBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpSloError("update_slo"))) + return { action: "updated" as const, slo: toSloConfigRow(updated) } + }), + render: (output) => renderMutation("SLO updated", output.slo), + }) + + server.define({ + name: "delete_slo", + title: "Delete SLO", + description: + "Delete an SLO and its burn-rate alert rule. Irreversible; requires confirm=true. To silence it instead, update_slo alerting_enabled=false. Org admins and engineers only.", + parameters: Schema.Struct({ + slo: P.text("The SLO's name or id"), + confirm: P.flag("Must be true. There is no undo."), + }), + output: DeleteSloOutput, + hints: { readOnly: false, destructive: true, idempotent: false }, + phrases: ["Deleting an SLO"], + handler: Effect.fn("McpTool.deleteSlo")(function* (params) { + // A member is refused before being asked to confirm, as create and update are. + const tenant = yield* canManage("delete_slo") + if (params.confirm !== true) { + return yield* new McpInvalidInputError({ + message: `Deletion not confirmed. Re-call delete_slo with confirm=true to delete "${params.slo}", or silence it with update_slo alerting_enabled=false.`, + parameter: "confirm", + }) + } + const service = yield* SlosService + const slo = yield* resolveSlo("delete_slo", tenant.orgId, params.slo) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.slo.id": slo.id }) + yield* service.delete(tenant.orgId, slo.id).pipe(Effect.mapError(toMcpSloError("delete_slo"))) + return { deleted: true, id: slo.id, name: slo.name } + }), + render: (output) => ({ + title: "SLO deleted", + blocks: [doc.text(`Deleted "${output.name}" (${output.id}) and its burn-rate alert rule.`)], + next: [doc.next("list_slos", {}, "the SLOs that remain")], + }), + }) +} diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index 8e2574fd24..0eb1216bad 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -23,6 +23,7 @@ import { VcsCommitService } from "@maple/backend/services/integrations/vcs/VcsCo import { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" +import { SlosService } from "@maple/backend/services/slos/SlosService" import { UsersReadService } from "@maple/backend/services/users/UsersReadService" import { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" @@ -58,6 +59,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(VcsCommitService)({}), Layer.mock(VcsSourceService)({}), Layer.mock(SetupAuditService)({}), + Layer.mock(SlosService)({}), Layer.mock(UptimeMonitorsService)({}), Layer.mock(UsersReadService)({}), Layer.mock(IngestRuleService)({}), diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index 0d9d871079..6353d97c0d 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -13,6 +13,7 @@ import { ListSecurityFindingsOutput, } from "./security" import { DeleteUptimeMonitorOutput, ListUptimeMonitorsOutput, UptimeMonitorMutationOutput } from "./uptime" +import { DeleteSloOutput, InspectSloOutput, ListSlosOutput, SloMutationOutput } from "./slo" import { ApplyStatusPageConfigOutput, GetStatusPageConfigOutput, @@ -143,11 +144,13 @@ export const McpToolOutputs = { create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, create_status_page_incident: StatusPageIncidentMutationOutput, + create_slo: SloMutationOutput, create_uptime_monitor: UptimeMonitorMutationOutput, db_query_volume: DbQueryVolumeOutput, delete_ingest_rule: DeleteIngestRuleOutput, delete_scrape_target: DeleteScrapeTargetOutput, delete_alert_rule: DeleteAlertRuleOutput, + delete_slo: DeleteSloOutput, delete_uptime_monitor: DeleteUptimeMonitorOutput, describe_dashboard_schema: DescribeDashboardSchemaOutput, describe_warehouse_tables: DescribeWarehouseTablesOutput, @@ -173,6 +176,7 @@ export const McpToolOutputs = { inspect_infra: InspectInfraOutput, inspect_security_audit_run: InspectSecurityAuditRunOutput, inspect_security_finding: InspectSecurityFindingOutput, + inspect_slo: InspectSloOutput, inspect_span: InspectSpanOutput, inspect_trace: InspectTraceOutput, inspect_user: InspectUserOutput, @@ -192,6 +196,7 @@ export const McpToolOutputs = { list_integrations: ListIntegrationsOutput, list_security_audit_runs: ListSecurityAuditRunsOutput, list_security_findings: ListSecurityFindingsOutput, + list_slos: ListSlosOutput, list_sources: ListSourcesOutput, list_status_pages: ListStatusPagesOutput, list_uptime_monitors: ListUptimeMonitorsOutput, @@ -243,6 +248,7 @@ export const McpToolOutputs = { update_dashboard_widget: UpdateDashboardWidgetOutput, update_ingest_rule: IngestRuleMutationOutput, update_scrape_target: ScrapeTargetMutationOutput, + update_slo: SloMutationOutput, update_uptime_monitor: UptimeMonitorMutationOutput, get_error_notification_policy: GetErrorNotificationPolicyOutput, update_error_notification_policy: UpdateErrorNotificationPolicyOutput, diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 121daed48f..54c0677436 100644 --- a/packages/domain/src/mcp-outputs/index.ts +++ b/packages/domain/src/mcp-outputs/index.ts @@ -16,6 +16,7 @@ export * from "./security" export * from "./sources" export * from "./ci" export * from "./uptime" +export * from "./slo" export * from "./status-pages" export * from "./users" export * from "./catalog" diff --git a/packages/domain/src/mcp-outputs/slo.ts b/packages/domain/src/mcp-outputs/slo.ts new file mode 100644 index 0000000000..ff7383293b --- /dev/null +++ b/packages/domain/src/mcp-outputs/slo.ts @@ -0,0 +1,98 @@ +import { Schema } from "effect" + +// --------------------------------------------------------------------------------------------- +// list_slos / inspect_slo / create_slo / update_slo / delete_slo + +export const SloSliKind = Schema.Literals(["availability", "latency"]) +export const SloBurnSeverity = Schema.Literals(["critical", "warning"]) + +/** The SLO's burn-rate alert: on or off, who each tier notifies, and the rule behind it. */ +export const SloAlertingRow = Schema.Struct({ + enabled: Schema.Boolean, + /** The hidden `slo_burn_rate` alert rule; null for an SLO saved before burn alerts. */ + alertRuleId: Schema.NullOr(Schema.String), + criticalDestinationIds: Schema.Array(Schema.String), + warningDestinationIds: Schema.Array(Schema.String), +}) + +export const SloConfigRow = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.NullOr(Schema.String), + serviceName: Schema.String, + /** Empty: every environment. */ + environments: Schema.Array(Schema.String), + sliKind: SloSliKind, + /** Latency SLIs only: a span is good when faster than this. */ + latencyThresholdMs: Schema.NullOr(Schema.Number), + targetPercent: Schema.Number, + windowDays: Schema.Number, + alerting: SloAlertingRow, + createdAt: Schema.String, + updatedAt: Schema.String, +}) + +/** An SLO with where it stands now; the status fields are null when the warehouse read failed. */ +export const SloListRow = Schema.Struct({ + ...SloConfigRow.fields, + /** Good share over the SLO window, 0 to 1; null without events or when the read failed. */ + sli: Schema.NullOr(Schema.Number), + /** Share of the window's error budget left: 1 untouched, negative when overspent. */ + budgetRemaining: Schema.NullOr(Schema.Number), + /** The highest burn rate across the 5m, 30m, 1h and 6h windows. */ + worstBurnRate: Schema.NullOr(Schema.Number), + worstBurnWindow: Schema.NullOr(Schema.String), + /** The highest alert tier whose long and short windows both burn past its factor. */ + burning: Schema.NullOr(SloBurnSeverity), + statusError: Schema.NullOr(Schema.String), +}) + +export const ListSlosOutput = Schema.Struct({ + slos: Schema.Array(SloListRow), + total: Schema.Number, + serviceFilter: Schema.optionalKey(Schema.String), +}) + +export const SloBurnWindowRow = Schema.Struct({ + /** `5m`, `30m`, `1h` or `6h`. */ + window: Schema.String, + windowMinutes: Schema.Number, + total: Schema.Number, + good: Schema.Number, + /** 1 spends exactly the budget over the SLO window; 0 when the window saw no events. */ + burnRate: Schema.Number, +}) + +export const SloBurnTierRow = Schema.Struct({ + severity: SloBurnSeverity, + factor: Schema.Number, + longWindow: Schema.String, + shortWindow: Schema.String, + breached: Schema.Boolean, +}) + +export const SloStatusRow = Schema.Struct({ + asOf: Schema.String, + total: Schema.Number, + good: Schema.Number, + sli: Schema.NullOr(Schema.Number), + budgetRemaining: Schema.Number, + burnWindows: Schema.Array(SloBurnWindowRow), + tiers: Schema.Array(SloBurnTierRow), +}) + +export const InspectSloOutput = Schema.Struct({ + slo: SloConfigRow, + status: SloStatusRow, +}) + +export const SloMutationOutput = Schema.Struct({ + action: Schema.Literals(["created", "updated"]), + slo: SloConfigRow, +}) + +export const DeleteSloOutput = Schema.Struct({ + deleted: Schema.Boolean, + id: Schema.String, + name: Schema.String, +}) From 6f3fff8137eca459bbabe23e1995cf0b3dca8947 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:25:56 +0100 Subject: [PATCH 2/6] fix(mcp): SLO tools show the no-events hold and find SLOs by rule id inspect_slo and list_slos read the evaluator's breach/hold/clear tier state, so an outage reads as holding, not quiet. Incident hints name the SLO by the incident's rule id, which survives an SLO rename. Tier text comes from the tier constants. --- apps/ai/src/mcp/lib/slos.ts | 88 ++++++++++++------- apps/ai/src/mcp/tool-descriptors.generated.ts | 8 +- .../mcp/tools/__tests__/alert-tools.test.ts | 3 +- .../src/mcp/tools/__tests__/slo-tools.test.ts | 7 ++ apps/ai/src/mcp/tools/slos.ts | 27 +++--- packages/domain/src/mcp-outputs/slo.ts | 4 + 6 files changed, 89 insertions(+), 48 deletions(-) diff --git a/apps/ai/src/mcp/lib/slos.ts b/apps/ai/src/mcp/lib/slos.ts index 26331d0e0f..0f5296029a 100644 --- a/apps/ai/src/mcp/lib/slos.ts +++ b/apps/ai/src/mcp/lib/slos.ts @@ -8,10 +8,16 @@ import { SloWindowDays, type OrgId, } from "@maple/domain/primitives" -import { SLO_BURN_TIERS, tierBreached, type SloBurnWindow, type SloSli } from "@maple/domain/slo" +import { + SLO_BURN_TIERS, + sloNameFromRuleName, + tierState, + windowLabel, + type SloBurnWindow, + type SloSli, +} from "@maple/domain/slo" import type { SloNotFoundError, SloPersistenceError, SloValidationError } from "@maple/domain/http" import { SlosService, type SloRecord, type SloStatus } from "@maple/backend/services/slos/SlosService" -import { sloRuleName } from "@maple/backend/services/slos/slo-alert-rule" import { McpInvalidInputError, McpQueryError } from "../tools/types" import { doc, type NextCall } from "./tool-doc" @@ -21,9 +27,17 @@ const LADDER = Arr.join(Arr.map(SLO_LATENCY_THRESHOLDS_MS, String), ", ") const iso = (ms: number) => new Date(ms).toISOString() -/** `5m`, `30m`, `1h`, `6h`. */ -export const windowLabel = (minutes: number): string => - minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m` +export { windowLabel } + +/** The alert tiers in one sentence, from the tier constants. */ +export const BURN_TIER_TEXT = `Its burn-rate alert ${Arr.join( + Arr.map( + SLO_BURN_TIERS, + (tier) => + `${tier.severity === "critical" ? "pages (critical)" : "opens a ticket (warning)"} when the ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)} windows both burn at ${tier.factor}x or more`, + ), + ", and ", +)}.` export const toSloConfigRow = (slo: SloRecord): typeof SloConfigRow.Type => ({ id: slo.id, @@ -48,10 +62,14 @@ export const toSloConfigRow = (slo: SloRecord): typeof SloConfigRow.Type => ({ /** The highest alert tier the windows breach, critical before warning. */ export const burningTier = (windows: ReadonlyArray): "critical" | "warning" | null => Option.match( - Arr.findFirst(SLO_BURN_TIERS, (tier) => tierBreached(tier, windows)), + Arr.findFirst(SLO_BURN_TIERS, (tier) => tierState(tier, windows) === "breach"), { onNone: () => null, onSome: (tier) => tier.severity }, ) +/** Whether a tier holds its state: no events in its short window, so it neither fires nor clears. */ +export const anyTierHolding = (windows: ReadonlyArray): boolean => + Arr.some(SLO_BURN_TIERS, (tier) => tierState(tier, windows) === "hold") + /** The fastest-burning window; the first one on a tie. */ export const worstWindow = (windows: ReadonlyArray): SloBurnWindow | undefined => Arr.sort(windows, Order.flip(Order.mapInput(Order.Number, (window: SloBurnWindow) => window.burnRate)))[0] @@ -69,13 +87,17 @@ export const toSloStatusRow = (status: SloStatus): typeof SloStatusRow.Type => ( good: window.good, burnRate: window.burnRate, })), - tiers: Arr.map(SLO_BURN_TIERS, (tier) => ({ - severity: tier.severity, - factor: tier.factor, - longWindow: windowLabel(tier.longWindowMinutes), - shortWindow: windowLabel(tier.shortWindowMinutes), - breached: tierBreached(tier, status.burnWindows), - })), + tiers: Arr.map(SLO_BURN_TIERS, (tier) => { + const state = tierState(tier, status.burnWindows) + return { + severity: tier.severity, + factor: tier.factor, + longWindow: windowLabel(tier.longWindowMinutes), + shortWindow: windowLabel(tier.shortWindowMinutes), + breached: state === "breach", + state, + } + }), }) export const percent = (ratio: number | null, digits = 3): string => @@ -141,45 +163,45 @@ export const toMcpSloError = (tool: string) => (error: SloServiceError) => { } } -/** An SLO by its id, REST id (`slo_…`) or exact name; names are unique per org. */ +/** An SLO by its id, REST id (`slo_…`), its alert rule's id (`alrt_…`) or exact name. */ export const resolveSlo = Effect.fnUntraced(function* (tool: string, orgId: OrgId, ref: string) { const service = yield* SlosService const slos = yield* service.list(orgId).pipe(Effect.mapError(toMcpSloError(tool))) const trimmed = Str.trim(ref) const id = decodePublicId(PublicIdPrefixes.slo, trimmed) ?? trimmed - const found = Arr.findFirst(slos, (slo) => slo.id === id || slo.name === trimmed) + const ruleId = decodePublicId(PublicIdPrefixes.alertRule, trimmed) ?? trimmed + const found = Arr.findFirst( + slos, + (slo) => slo.id === id || slo.alertRuleId === ruleId || slo.name === trimmed, + ) if (Option.isNone(found)) { return yield* new McpInvalidInputError({ - message: `No SLO named "${ref}". list_slos lists them.`, + message: `No SLO with the name or id "${ref}". list_slos lists them.`, parameter: "slo", }) } return found.value }) -const SLO_RULE_PREFIX = sloRuleName("") - /** * For burn-rate incidents: the SLOs behind them (their rules are hidden from list_alert_rules), - * named from the rule name, else the SLO list. + * keyed by the incident's rule id, which survives an SLO rename where its stored name does not. */ export const sloIncidentNext = ( - incidents: ReadonlyArray<{ readonly signalType: string; readonly ruleName: string }>, + incidents: ReadonlyArray<{ + readonly signalType: string + readonly ruleId: string + readonly ruleName: string + }>, ): ReadonlyArray => { - const burning = Arr.filter(incidents, (incident) => incident.signalType === "slo_burn_rate") - if (burning.length === 0) return [] - const names = Arr.dedupe( - Arr.flatMap(burning, (incident) => - Str.startsWith(SLO_RULE_PREFIX)(incident.ruleName) - ? [Str.slice(SLO_RULE_PREFIX.length)(incident.ruleName)] - : [], - ), + const burning = Arr.dedupeWith( + Arr.filter(incidents, (incident) => incident.signalType === "slo_burn_rate"), + (a: { readonly ruleId: string }, b: { readonly ruleId: string }) => a.ruleId === b.ruleId, ) - return names.length === 0 - ? [doc.next("list_slos", {}, "the SLOs behind these burn-rate incidents")] - : Arr.map(names.slice(0, 3), (name) => - doc.next("inspect_slo", { slo: name }, `the burn windows of SLO "${name}"`), - ) + return Arr.map(burning.slice(0, 3), (incident) => { + const name = sloNameFromRuleName(incident.ruleName) ?? incident.ruleId + return doc.next("inspect_slo", { slo: incident.ruleId }, `the burn windows of SLO "${name}"`) + }) } /** Every incident is a burn-rate one, so list_alert_rules (which hides their rules) cannot help. */ diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 04715b9ff2..9728e90fad 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -76,10 +76,10 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"create_uptime_monitor","title":"Create Uptime Monitor","description":"Start monitoring an endpoint. kind=http requests `url` and expects `expected_status` (default 2xx), optionally under `max_response_ms` and with `body_contains`; kind=tcp connects to `host`:`port`; kind=dns resolves `host` (`record_type`, optional `expected` answer); kind=heartbeat waits for your job to ping a URL every `heartbeat_interval_seconds` or on a `cron` schedule, plus `grace_seconds`. A monitor that goes down opens an alert incident and notifies `destination_ids`. Org admins and engineers only. Returns the monitor and, for a heartbeat, its ping URL and a curl line for the job.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the monitor, unique among the org's monitors and alert rules"},"kind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"description":"What the monitor checks"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations notified when the monitor goes down (list_alert_destinations lists them). Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Create Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true}}, {"name":"update_uptime_monitor","title":"Update Uptime Monitor","description":"Change an uptime monitor: pause it (enabled=false) or resume it (enabled=true, which restarts it from pending), or change its name, target, assertions, schedule, interval, service or destinations. Fields left out keep their value; a monitor cannot change kind. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id (list_uptime_monitors lists them)"},"enabled":{"type":"boolean","description":"false pauses the monitor, true resumes it"},"name":{"type":"string","description":"A new name"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"clear_service":{"type":"boolean","description":"true unlinks the monitor from its service; not with `service`"},"clear_max_response_ms":{"type":"boolean","description":"http: true removes the response time check; not with `max_response_ms`"},"clear_body_contains":{"type":"boolean","description":"http: true removes the body contains check; not with `body_contains`"},"clear_expected":{"type":"boolean","description":"dns: true removes the expected answer; not with `expected`"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Replaces the alert destinations notified when the monitor goes down; [] notifies none. Each is its UUID or its REST id (dest_\u2026)"}},"required":["monitor"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Update Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true}}, {"name":"delete_uptime_monitor","title":"Delete Uptime Monitor","description":"Delete an uptime monitor and its alert rule. Check spans already in the traces stay. Irreversible; requires confirm=true. To stop it for a while, update_uptime_monitor enabled=false instead. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["monitor","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete Uptime Monitor","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, - {"name":"list_slos","title":"List SLOs","description":"Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Only SLOs on this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnWindow":{"anyOf":[{"type":"string"},{"type":"null"}]},"burning":{"anyOf":[{"type":"string","enum":["critical","warning"]},{"type":"null"}]},"statusError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt","sli","budgetRemaining","worstBurnRate","worstBurnWindow","burning","statusError"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"serviceFilter":{"type":"string"}},"required":["slos","total"],"additionalProperties":true},"annotations":{"title":"List SLOs","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"inspect_slo","title":"Inspect SLO","description":"One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true},"status":{"type":"object","properties":{"asOf":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnWindows":{"type":"array","items":{"type":"object","properties":{"window":{"type":"string"},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["window","windowMinutes","total","good","burnRate"],"additionalProperties":true}},"tiers":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string","enum":["critical","warning"]},"factor":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"longWindow":{"type":"string"},"shortWindow":{"type":"string"},"breached":{"type":"boolean"}},"required":["severity","factor","longWindow","shortWindow","breached"],"additionalProperties":true}}},"required":["asOf","total","good","sli","budgetRemaining","burnWindows","tiers"],"additionalProperties":true}},"required":["slo","status"],"additionalProperties":true},"annotations":{"title":"Inspect SLO","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"create_slo","title":"Create SLO","description":"Define an SLO on one service's entry-point spans. sli=availability counts spans that are not errors as good; sli=latency counts spans faster than threshold_ms. target_percent is the good share to hold (50 to 99.999, e.g. 99.9) over window_days. Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) at 6x over 6h and 30m. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the SLO, unique among the org's SLOs"},"service":{"type":"string","description":"The service whose entry-point spans count (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"What makes a span good: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"Target share of good spans in percent, 50 to 99.999 (e.g. 99.9)"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","service","sli","target_percent"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Create SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, - {"name":"update_slo","title":"Update SLO","description":"Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new service (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"A new SLI: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"A new target share of good spans in percent, 50 to 99.999"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Update SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"list_slos","title":"List SLOs","description":"Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Only SLOs on this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnWindow":{"anyOf":[{"type":"string"},{"type":"null"}]},"burning":{"anyOf":[{"type":"string","enum":["critical","warning"]},{"type":"null"}]},"holding":{"type":"boolean"},"statusError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt","sli","budgetRemaining","worstBurnRate","worstBurnWindow","burning","holding","statusError"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"serviceFilter":{"type":"string"}},"required":["slos","total"],"additionalProperties":true},"annotations":{"title":"List SLOs","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"inspect_slo","title":"Inspect SLO","description":"One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true},"status":{"type":"object","properties":{"asOf":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnWindows":{"type":"array","items":{"type":"object","properties":{"window":{"type":"string"},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["window","windowMinutes","total","good","burnRate"],"additionalProperties":true}},"tiers":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string","enum":["critical","warning"]},"factor":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"longWindow":{"type":"string"},"shortWindow":{"type":"string"},"breached":{"type":"boolean"},"state":{"type":"string","enum":["breach","hold","clear"]}},"required":["severity","factor","longWindow","shortWindow","breached","state"],"additionalProperties":true}}},"required":["asOf","total","good","sli","budgetRemaining","burnWindows","tiers"],"additionalProperties":true}},"required":["slo","status"],"additionalProperties":true},"annotations":{"title":"Inspect SLO","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"create_slo","title":"Create SLO","description":"Define an SLO on one service's entry-point spans. sli=availability counts spans that are not errors as good; sli=latency counts spans faster than threshold_ms. target_percent is the good share to hold (50 to 99.999, e.g. 99.9) over window_days. Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) when the 6h and 30m windows both burn at 6x or more. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the SLO, unique among the org's SLOs"},"service":{"type":"string","description":"The service whose entry-point spans count (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"What makes a span good: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"Target share of good spans in percent, 50 to 99.999 (e.g. 99.9)"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","service","sli","target_percent"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Create SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"update_slo","title":"Update SLO","description":"Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new service (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"A new SLI: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"A new target share of good spans in percent, 50 to 99.999"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Update SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_slo","title":"Delete SLO","description":"Delete an SLO and its burn-rate alert rule. Irreversible; requires confirm=true. To silence it instead, update_slo alerting_enabled=false. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["slo","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete SLO","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_ingest_rules","title":"List Ingest Rules","description":"The organization's ingest rules: what telemetry is dropped or redacted before Maple stores or bills it, in the order they run (drop rules first, then redact rules by position).","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"position":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"signals":{"type":"array","items":{"type":"string","enum":["traces","logs","metrics"]}},"conditions":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":true}},"action":{"$ref":"#/$defs/IngestRuleAction"},"summary":{"type":"string"}},"required":["id","name","enabled","position","signals","conditions","action","summary"],"additionalProperties":true}}},"required":["rules"],"additionalProperties":true,"$defs":{"IngestRuleDropAction":{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":true,"title":"Drop"},"IngestRuleRedactAction":{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":true,"title":"Redact"},"IngestRuleAction":{"anyOf":[{"$ref":"#/$defs/IngestRuleDropAction"},{"$ref":"#/$defs/IngestRuleRedactAction"}],"title":"Ingest rule action"}}},"annotations":{"title":"List Ingest Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"preview_ingest_rule","title":"Preview Ingest Rule","description":"Run an ingest rule (an existing one by `rule`, or a draft from signals/conditions/action) over recent stored traces or logs and report how many records it would drop or redact, with examples. Changes nothing. Recent data may already be redacted by existing rules; metrics are not previewed.","inputSchema":{"type":"object","properties":{"rule":{"type":"string","description":"An existing rule's name or id (list_ingest_rules lists them)"},"signals":{"type":"array","items":{"type":"string"},"description":"Draft: signals, traces, logs, metrics"},"conditions":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":false}},"description":"Conditions that must all match, e.g. [{\"field\":\"severity\",\"op\":\"lt\",\"value\":\"info\"}] or [{\"field\":\"attribute\",\"key\":\"http.route\",\"op\":\"in\",\"values\":[\"/health\"]}]. Fields: service_name, name, attribute, resource_attribute, body, severity, span_kind, span_status. Ops: eq, neq, in, not_in, prefix, contains, regex, exists, not_exists, lt, gte (severity). Empty matches every record (redact rules only)."},"action":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":false,"title":"Drop"},{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":false,"title":"Redact"}],"title":"Ingest rule action"},"description":"Draft: What to do: {\"type\":\"drop\",\"keep_percent\":0} or {\"type\":\"redact\",\"keys\":[\"*password*\"],\"detectors\":[\"email\",\"credit_card\",\"jwt\",\"api_token\",\"ipv4\",\"ipv6\",\"phone\",\"iban\"],\"patterns\":[\"ORD-[0-9]+\"],\"scopes\":[\"attributes\",\"resource\",\"events\",\"body\"],\"method\":\"mask\"|\"hash\"|\"remove\"}."},"signal":{"type":"string","enum":["traces","logs"],"description":"Which signal to sample; defaults to the rule's first of traces/logs"},"service":{"type":"string","description":"Only sample this service"},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 1 hour before end_time. Covers at most 1 day; a wider window is narrowed to the 1 day before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"limit":{"type":"number","description":"Max sample records to return (default 200, max 500)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"signal":{"type":"string","enum":["traces","logs","metrics"]},"sampled":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"dropped":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedRecords":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedValues":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"examples":{"type":"array","items":{"type":"object","properties":{"service":{"type":"string"},"label":{"type":"string"},"outcome":{"type":"string","enum":["dropped","redacted"]},"changes":{"type":"array","items":{"type":"string"}}},"required":["service","label","outcome","changes"],"additionalProperties":true}},"problems":{"type":"array","items":{"type":"string"}},"notes":{"type":"array","items":{"type":"string"}}},"required":["signal","sampled","dropped","redactedRecords","redactedValues","examples","problems"],"additionalProperties":true},"annotations":{"title":"Preview Ingest Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts index 7dece5d9a8..6027cfc7d8 100644 --- a/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts @@ -460,7 +460,8 @@ describe("alert tools", () => { status: "resolved", resolvedAt: NOW, }) - const inspect = '`inspect_slo slo="Checkout availability"`' + // Keyed by the rule id, which survives a rename of the SLO where the stored name does not. + const inspect = `\`inspect_slo slo="${burn.ruleId}"\`` for (const tool of ["list_alert_incidents", "get_incident_timeline"]) { const onlySlo = text(await ok(tool, { status: "resolved" }, { extraIncidents: [burn] })) expect(onlySlo, tool).toContain(inspect) diff --git a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts index 4128b8fddc..49be465574 100644 --- a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts @@ -223,6 +223,13 @@ describe("inspect_slo", () => { expect(byId.slo.name).toBe("Search latency") expect(Arr.every(byId.status.tiers, (tier) => !tier.breached)).toBe(true) + // The incident tools hint at inspect_slo by the incident's rule id. + const byRule = decode( + InspectSloOutput, + await runToolDirect(member, "inspect_slo", { slo: byId.slo.alerting.alertRuleId ?? "missing" }), + ) + expect(byRule.slo.id).toBe(byId.slo.id) + const missing = await runToolDirect(member, "inspect_slo", { slo: "Nope" }) expect(missing.isError).toBe(true) expect(markdown(missing)).toContain("list_slos") diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts index 3efc553cea..aae00636fb 100644 --- a/apps/ai/src/mcp/tools/slos.ts +++ b/apps/ai/src/mcp/tools/slos.ts @@ -22,6 +22,8 @@ import { doc, type NextCall } from "../lib/tool-doc" import { buildSli, burn, + anyTierHolding, + BURN_TIER_TEXT, burningTier, decodeWindowDays, percent, @@ -79,9 +81,6 @@ const alertingInput = Effect.fnUntraced(function* (params: { } }) -const BURN_TIER_TEXT = - "Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) at 6x over 6h and 30m." - const destinationsCell = (ids: ReadonlyArray) => (ids.length === 0 ? "none" : Arr.join(ids, ", ")) const configFields = (row: typeof SloConfigRow.Type) => @@ -142,11 +141,11 @@ const sloParameters = { alerting_enabled: P.optionalFlag("Whether the SLO's burn-rate alert fires (default true)"), critical_destination_ids: P.optionalResourceIdList( "alertDestination", - "Alert destinations paged by a fast burn (14.4x over 1h and 5m); list_alert_destinations lists them", + "Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them", ), warning_destination_ids: P.optionalResourceIdList( "alertDestination", - "Alert destinations ticketed by a slow burn (6x over 6h and 30m); a critical incident notifies them too", + "Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too", ), } as const @@ -184,6 +183,7 @@ export function registerSloTools(server: McpToolRegistrar) { worstBurnRate: null, worstBurnWindow: null, burning: null, + holding: false, statusError: error.message, }), onSuccess: (status): typeof SloListRow.Type => { @@ -196,6 +196,7 @@ export function registerSloTools(server: McpToolRegistrar) { worstBurnWindow: worst === undefined ? null : windowLabel(worst.windowMinutes), burning: burningTier(status.burnWindows), + holding: anyTierHolding(status.burnWindows), statusError: null, } }, @@ -254,12 +255,13 @@ export function registerSloTools(server: McpToolRegistrar) { row.worstBurnWindow === null ? "-" : `${burn(row.worstBurnRate)} (${row.worstBurnWindow})`, - row.burning ?? "-", + row.burning ?? (row.holding ? "holding (no recent events)" : "-"), ]), ), ], - next: Arr.map(Arr.filter(output.slos, (row) => row.burning !== null).slice(0, 3), (row) => - doc.next("inspect_slo", { slo: row.name }, `the burn windows of "${row.name}"`), + next: Arr.map( + Arr.filter(output.slos, (row) => row.burning !== null || row.holding).slice(0, 3), + (row) => doc.next("inspect_slo", { slo: row.name }, `the burn windows of "${row.name}"`), ), }), }) @@ -301,7 +303,12 @@ export function registerSloTools(server: McpToolRegistrar) { (tier) => `The ${tier.severity} tier is breaching: ${tier.longWindow} and ${tier.shortWindow} both burn at ${tier.factor}x or more.`, ), - ...(output.status.budgetRemaining < 0 ? ["The error budget is spent."] : []), + ...Arr.map( + Arr.filter(output.status.tiers, (tier) => tier.state === "hold"), + (tier) => + `The ${tier.severity} tier is holding its state: no events in the last ${tier.shortWindow}, so an open incident stays open.`, + ), + ...(output.status.budgetRemaining <= 0 ? ["The error budget is spent."] : []), ...(output.status.total === 0 ? [`No entry-point spans from ${output.slo.serviceName} in the window yet.`] : []), @@ -329,7 +336,7 @@ export function registerSloTools(server: McpToolRegistrar) { tier.severity, `${tier.factor}x`, `${tier.longWindow} and ${tier.shortWindow}`, - tier.breached ? "yes" : "no", + tier.state === "breach" ? "yes" : tier.state === "hold" ? "holding" : "no", ]), ), ], diff --git a/packages/domain/src/mcp-outputs/slo.ts b/packages/domain/src/mcp-outputs/slo.ts index ff7383293b..0a806792a9 100644 --- a/packages/domain/src/mcp-outputs/slo.ts +++ b/packages/domain/src/mcp-outputs/slo.ts @@ -44,6 +44,8 @@ export const SloListRow = Schema.Struct({ worstBurnWindow: Schema.NullOr(Schema.String), /** The highest alert tier whose long and short windows both burn past its factor. */ burning: Schema.NullOr(SloBurnSeverity), + /** A tier holds its state: its short window saw no events (an outage or an ingest gap). */ + holding: Schema.Boolean, statusError: Schema.NullOr(Schema.String), }) @@ -69,6 +71,8 @@ export const SloBurnTierRow = Schema.Struct({ longWindow: Schema.String, shortWindow: Schema.String, breached: Schema.Boolean, + /** `hold`: no events in the short window, so the alert keeps its state instead of resolving. */ + state: Schema.Literals(["breach", "hold", "clear"]), }) export const SloStatusRow = Schema.Struct({ From 3fd253bf2c7c796352db185b3d53b2fae11ff4b7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:50:43 +0100 Subject: [PATCH 3/6] fix(mcp): update_slo clears a description with clear_description P.optionalText reads a blank string as absent, so the empty-string branch could never run. --- apps/ai/src/mcp/tool-descriptors.generated.ts | 2 +- .../ai/src/mcp/tools/__tests__/slo-tools.test.ts | 16 ++++++++++++++++ apps/ai/src/mcp/tools/slos.ts | 10 ++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 08243d4481..1fa7ac6c46 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -79,7 +79,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"list_slos","title":"List SLOs","description":"Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Only SLOs on this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnWindow":{"anyOf":[{"type":"string"},{"type":"null"}]},"burning":{"anyOf":[{"type":"string","enum":["critical","warning"]},{"type":"null"}]},"holding":{"type":"boolean"},"statusError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt","sli","budgetRemaining","worstBurnRate","worstBurnWindow","burning","holding","statusError"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"serviceFilter":{"type":"string"}},"required":["slos","total"],"additionalProperties":true},"annotations":{"title":"List SLOs","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"inspect_slo","title":"Inspect SLO","description":"One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true},"status":{"type":"object","properties":{"asOf":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnWindows":{"type":"array","items":{"type":"object","properties":{"window":{"type":"string"},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["window","windowMinutes","total","good","burnRate"],"additionalProperties":true}},"tiers":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string","enum":["critical","warning"]},"factor":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"longWindow":{"type":"string"},"shortWindow":{"type":"string"},"breached":{"type":"boolean"},"state":{"type":"string","enum":["breach","hold","clear"]}},"required":["severity","factor","longWindow","shortWindow","breached","state"],"additionalProperties":true}}},"required":["asOf","total","good","sli","budgetRemaining","burnWindows","tiers"],"additionalProperties":true}},"required":["slo","status"],"additionalProperties":true},"annotations":{"title":"Inspect SLO","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_slo","title":"Create SLO","description":"Define an SLO on one service's entry-point spans. sli=availability counts spans that are not errors as good; sli=latency counts spans faster than threshold_ms. target_percent is the good share to hold (50 to 99.999, e.g. 99.9) over window_days. Its burn-rate alert pages (critical) when the 1h and 5m windows both burn at 14.4x or more, and opens a ticket (warning) when the 6h and 30m windows both burn at 6x or more. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the SLO, unique among the org's SLOs"},"service":{"type":"string","description":"The service whose entry-point spans count (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"What makes a span good: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"Target share of good spans in percent, 50 to 99.999 (e.g. 99.9)"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","service","sli","target_percent"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Create SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, - {"name":"update_slo","title":"Update SLO","description":"Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new service (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"A new SLI: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"A new target share of good spans in percent, 50 to 99.999"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Update SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, + {"name":"update_slo","title":"Update SLO","description":"Change an SLO: its name, description, service, environments, SLI, target, window, or its burn-rate alerting (alerting_enabled, destinations per tier). Fields left out keep their value. Changing threshold_ms alone keeps a latency SLI. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new service (exact `service.name`)"},"sli":{"type":"string","enum":["availability","latency"],"description":"A new SLI: not an error, or faster than threshold_ms"},"target_percent":{"type":"number","description":"A new target share of good spans in percent, 50 to 99.999"},"description":{"type":"string","description":"What the SLO promises, for people reading it"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments whose spans count (e.g. production). Omit or [] for every environment"},"threshold_ms":{"type":"number","description":"latency SLI only: a span is good when faster than this, one of 50, 100, 250, 500, 1000, 2500, 5000, 10000"},"window_days":{"type":"number","description":"Rolling window the error budget covers: 7, 28 or 30 days (default 30)"},"alerting_enabled":{"type":"boolean","description":"Whether the SLO's burn-rate alert fires (default true)"},"critical_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations paged by the critical (fast burn) tier; list_alert_destinations lists them. Each is its UUID or its REST id (dest_\u2026)"},"warning_destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations ticketed by the warning (slow burn) tier; a critical incident notifies them too. Each is its UUID or its REST id (dest_\u2026)"},"clear_description":{"type":"boolean","description":"Remove the SLO's description"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true}},"required":["action","slo"],"additionalProperties":true},"annotations":{"title":"Update SLO","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_slo","title":"Delete SLO","description":"Delete an SLO and its burn-rate alert rule. Irreversible; requires confirm=true. To silence it instead, update_slo alerting_enabled=false. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["slo","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete SLO","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_ingest_rules","title":"List Ingest Rules","description":"The organization's ingest rules: what telemetry is dropped or redacted before Maple stores or bills it, in the order they run (drop rules first, then redact rules by position).","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"position":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"signals":{"type":"array","items":{"type":"string","enum":["traces","logs","metrics"]}},"conditions":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":true}},"action":{"$ref":"#/$defs/IngestRuleAction"},"summary":{"type":"string"}},"required":["id","name","enabled","position","signals","conditions","action","summary"],"additionalProperties":true}}},"required":["rules"],"additionalProperties":true,"$defs":{"IngestRuleDropAction":{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":true,"title":"Drop"},"IngestRuleRedactAction":{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":true,"title":"Redact"},"IngestRuleAction":{"anyOf":[{"$ref":"#/$defs/IngestRuleDropAction"},{"$ref":"#/$defs/IngestRuleRedactAction"}],"title":"Ingest rule action"}}},"annotations":{"title":"List Ingest Rules","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"preview_ingest_rule","title":"Preview Ingest Rule","description":"Run an ingest rule (an existing one by `rule`, or a draft from signals/conditions/action) over recent stored traces or logs and report how many records it would drop or redact, with examples. Changes nothing. Recent data may already be redacted by existing rules; metrics are not previewed.","inputSchema":{"type":"object","properties":{"rule":{"type":"string","description":"An existing rule's name or id (list_ingest_rules lists them)"},"signals":{"type":"array","items":{"type":"string"},"description":"Draft: signals, traces, logs, metrics"},"conditions":{"type":"string","contentMediaType":"application/json","contentSchema":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["service_name","name","attribute","resource_attribute","body","severity","span_kind","span_status"]},"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq","in","not_in","prefix","contains","regex","exists","not_exists","lt","gte"]},"value":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}},"required":["field","op"],"additionalProperties":false}},"description":"Conditions that must all match, e.g. [{\"field\":\"severity\",\"op\":\"lt\",\"value\":\"info\"}] or [{\"field\":\"attribute\",\"key\":\"http.route\",\"op\":\"in\",\"values\":[\"/health\"]}]. Fields: service_name, name, attribute, resource_attribute, body, severity, span_kind, span_status. Ops: eq, neq, in, not_in, prefix, contains, regex, exists, not_exists, lt, gte (severity). Empty matches every record (redact rules only)."},"action":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["drop"]},"keep_percent":{"type":"integer","minimum":0,"maximum":100,"description":"Share of matching records kept, 0 to 100. Sampled on trace id, so a trace is kept or dropped whole.","examples":[0]}},"required":["type","keep_percent"],"additionalProperties":false,"title":"Drop"},{"type":"object","properties":{"type":{"type":"string","enum":["redact"]},"keys":{"type":"array","items":{"type":"string"},"description":"Attribute keys whose whole value is redacted. Case-insensitive; `*` matches any run of characters.","examples":[["*password*","http.request.header.authorization"]]},"detectors":{"type":"array","items":{"type":"string","enum":["email","credit_card","jwt","api_token","ipv4","ipv6","phone","iban"]},"description":"Built-in detectors; only the matched text is replaced.","examples":[["email","credit_card"]]},"patterns":{"type":"array","items":{"type":"string"},"description":"Custom regular expressions (no lookaround or backreferences); only the matched text is replaced.","examples":[["ORD-[0-9]+"]]},"scopes":{"type":"array","items":{"type":"string","enum":["attributes","resource","events","body"]},"description":"Where to look: record `attributes`, `resource` attributes, span `events`, log `body`.","examples":[["attributes","body"]]},"method":{"type":"string","enum":["mask","hash","remove"],"description":"`mask` writes `[REDACTED]`, `hash` writes a per-org HMAC (`hash:`), `remove` deletes key matches.","examples":["mask"]}},"required":["type","keys","detectors","patterns","scopes","method"],"additionalProperties":false,"title":"Redact"}],"title":"Ingest rule action"},"description":"Draft: What to do: {\"type\":\"drop\",\"keep_percent\":0} or {\"type\":\"redact\",\"keys\":[\"*password*\"],\"detectors\":[\"email\",\"credit_card\",\"jwt\",\"api_token\",\"ipv4\",\"ipv6\",\"phone\",\"iban\"],\"patterns\":[\"ORD-[0-9]+\"],\"scopes\":[\"attributes\",\"resource\",\"events\",\"body\"],\"method\":\"mask\"|\"hash\"|\"remove\"}."},"signal":{"type":"string","enum":["traces","logs"],"description":"Which signal to sample; defaults to the rule's first of traces/logs"},"service":{"type":"string","description":"Only sample this service"},"start_time":{"type":"string","description":"Window start, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601). Default: 1 hour before end_time. Covers at most 1 day; a wider window is narrowed to the 1 day before end_time."},"end_time":{"type":"string","description":"Window end, UTC, same format. Default: now."},"limit":{"type":"number","description":"Max sample records to return (default 200, max 500)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"signal":{"type":"string","enum":["traces","logs","metrics"]},"sampled":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"dropped":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedRecords":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"redactedValues":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"examples":{"type":"array","items":{"type":"object","properties":{"service":{"type":"string"},"label":{"type":"string"},"outcome":{"type":"string","enum":["dropped","redacted"]},"changes":{"type":"array","items":{"type":"string"}}},"required":["service","label","outcome","changes"],"additionalProperties":true}},"problems":{"type":"array","items":{"type":"string"}},"notes":{"type":"array","items":{"type":"string"}}},"required":["signal","sampled","dropped","redactedRecords","redactedValues","examples","problems"],"additionalProperties":true},"annotations":{"title":"Preview Ingest Rule","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts index 49be465574..3b801aa980 100644 --- a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts @@ -276,6 +276,22 @@ describe("update_slo", () => { expect(availability.isError).toBe(true) expect(markdown(availability)).toContain("threshold_ms applies to a latency SLI") }) + + it("sets a description, and clear_description removes it", async () => { + const described = decode( + SloMutationOutput, + await runToolDirect(admin, "update_slo", { + slo: "Search latency", + description: "Search stays fast", + }), + ) + expect(described.slo.description).toBe("Search stays fast") + const cleared = decode( + SloMutationOutput, + await runToolDirect(admin, "update_slo", { slo: "Search latency", clear_description: true }), + ) + expect(cleared.slo.description).toBeNull() + }) }) describe("delete_slo", () => { diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts index aae00636fb..072ec04bf5 100644 --- a/apps/ai/src/mcp/tools/slos.ts +++ b/apps/ai/src/mcp/tools/slos.ts @@ -406,6 +406,7 @@ export function registerSloTools(server: McpToolRegistrar) { sli: P.optionalOneOf(SLI_KINDS, "A new SLI: not an error, or faster than threshold_ms"), target_percent: P.optionalNumber("A new target share of good spans in percent, 50 to 99.999"), ...sloParameters, + clear_description: P.optionalFlag("Remove the SLO's description"), }), aliases: P.SERVICE_ALIASES, output: SloMutationOutput, @@ -423,10 +424,11 @@ export function registerSloTools(server: McpToolRegistrar) { const updated = yield* service .update(tenant.orgId, current.id, { ...(params.name === undefined ? undefined : { name: params.name }), - // An empty description removes it. - ...(params.description === undefined - ? undefined - : { description: params.description === "" ? null : params.description }), + ...(params.clear_description === true + ? { description: null } + : params.description === undefined + ? undefined + : { description: params.description }), ...(params.service === undefined ? undefined : { serviceName: params.service }), ...(params.environments === undefined ? undefined From 5d2e6a5c954406f49c2005440b4e7b59f57d5be3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:05:32 +0100 Subject: [PATCH 4/6] perf(mcp): list_slos reads every status in one batch --- .../src/mcp/tools/__tests__/slo-tools.test.ts | 30 +++++++++ apps/ai/src/mcp/tools/slos.ts | 65 +++++++++---------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts index 3b801aa980..a4d71dbad3 100644 --- a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts @@ -21,7 +21,37 @@ const badOf = (sql: string): number => { onNone: () => 0, onSome: (service) => BAD_PER_MILLE[service] }, ) +/** One batched row per service, in production, as the SLOs list reads them. */ +const perService = (row: (service: keyof typeof BAD_PER_MILLE) => Row) => + Arr.map(Rec.keys(BAD_PER_MILLE), (service) => ({ + serviceName: service, + environment: "production", + ...row(service), + })) + const fixtures: FixtureRule[] = [ + // The list's batched burn read: each window full of the same uniform minutes. + { + match: (sql) => Str.includes("AS total360")(sql), + rows: () => + perService((service) => + Rec.fromEntries( + Arr.flatMap([5, 30, 60, 360], (minutes) => [ + [`total${minutes}`, minutes * 1000], + [`good${minutes}`, minutes * (1000 - BAD_PER_MILLE[service])], + ]), + ), + ), + }, + // The list's batched window totals. + { + match: (sql) => Str.includes("AS serviceName")(sql), + rows: () => + perService((service) => ({ + total: 1_000_000, + good: BAD_PER_MILLE[service] > 0 ? 999_500 : 1_000_000, + })), + }, // The burn read: 400 minute buckets of uniform traffic ending just before now. { match: (sql) => diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts index 072ec04bf5..31f99b9bd1 100644 --- a/apps/ai/src/mcp/tools/slos.ts +++ b/apps/ai/src/mcp/tools/slos.ts @@ -2,7 +2,7 @@ * SLOs: a target share of good entry-point spans for one service over a rolling window, with a * hidden burn-rate alert rule. Anyone may read them; writes need `alerts:manage`, like the REST routes. */ -import { Effect, Order, Schema, Array as Arr } from "effect" +import { Effect, Order, Result, Schema, Array as Arr } from "effect" import { DeleteSloOutput, InspectSloOutput, @@ -170,40 +170,37 @@ export function registerSloTools(server: McpToolRegistrar) { slos, (slo) => params.service === undefined || slo.serviceName === params.service, ) - // One SLO's failed read leaves its status empty instead of failing the whole list. - const rows = yield* Effect.forEach( - shown, - (slo) => - service.status(tenant, slo.id).pipe( - Effect.match({ - onFailure: (error): typeof SloListRow.Type => ({ - ...toSloConfigRow(slo), - sli: null, - budgetRemaining: null, - worstBurnRate: null, - worstBurnWindow: null, - burning: null, - holding: false, - statusError: error.message, - }), - onSuccess: (status): typeof SloListRow.Type => { - const worst = worstWindow(status.burnWindows) - return { - ...toSloConfigRow(slo), - sli: status.sli, - budgetRemaining: status.budgetRemaining, - worstBurnRate: worst?.burnRate ?? null, - worstBurnWindow: - worst === undefined ? null : windowLabel(worst.windowMinutes), - burning: burningTier(status.burnWindows), - holding: anyTierHolding(status.burnWindows), - statusError: null, - } - }, - }), - ), - { concurrency: 4 }, + // One batched read for every SLO; if it fails, the list still shows each SLO's config. + const statuses = yield* service.statuses(tenant).pipe( + Effect.map((read) => new Map(Arr.map(read, (row) => [row.slo.id, row.status] as const))), + Effect.result, ) + const rows = Arr.map(shown, (slo): typeof SloListRow.Type => { + const status = Result.isSuccess(statuses) ? statuses.success.get(slo.id) : undefined + if (status === undefined) { + return { + ...toSloConfigRow(slo), + sli: null, + budgetRemaining: null, + worstBurnRate: null, + worstBurnWindow: null, + burning: null, + holding: false, + statusError: Result.isFailure(statuses) ? statuses.failure.message : "No status read", + } + } + const worst = worstWindow(status.burnWindows) + return { + ...toSloConfigRow(slo), + sli: status.sli, + budgetRemaining: status.budgetRemaining, + worstBurnRate: worst?.burnRate ?? null, + worstBurnWindow: worst === undefined ? null : windowLabel(worst.windowMinutes), + burning: burningTier(status.burnWindows), + holding: anyTierHolding(status.burnWindows), + statusError: null, + } + }) const sorted = Arr.sort(rows, LIST_ORDER) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "result.rowCount": sorted.length }) return { From d52bbd22aa5fe4a6e123c41a2c2898f8a88a564c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:15:11 +0100 Subject: [PATCH 5/6] fix(mcp): list_slos logs a failed status read and tells the model what to do --- apps/ai/src/mcp/tools/slos.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts index 31f99b9bd1..aadb55148a 100644 --- a/apps/ai/src/mcp/tools/slos.ts +++ b/apps/ai/src/mcp/tools/slos.ts @@ -173,6 +173,12 @@ export function registerSloTools(server: McpToolRegistrar) { // One batched read for every SLO; if it fails, the list still shows each SLO's config. const statuses = yield* service.statuses(tenant).pipe( Effect.map((read) => new Map(Arr.map(read, (row) => [row.slo.id, row.status] as const))), + Effect.tapError((error) => + Effect.andThen( + Effect.annotateCurrentSpan({ "maple.slo.status_error": error._tag }), + Effect.logWarning("list_slos could not read SLO statuses", error.message), + ), + ), Effect.result, ) const rows = Arr.map(shown, (slo): typeof SloListRow.Type => { @@ -186,7 +192,10 @@ export function registerSloTools(server: McpToolRegistrar) { worstBurnWindow: null, burning: null, holding: false, - statusError: Result.isFailure(statuses) ? statuses.failure.message : "No status read", + // The model gets what to do next; the raw error is in the log and on the span. + statusError: Result.isFailure(statuses) + ? "The status read failed; inspect_slo reads one SLO's status on its own" + : "No status read", } } const worst = worstWindow(status.burnWindows) From 1340b3544f8ef181dfd3083db25e810860c2697f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:23:22 +0100 Subject: [PATCH 6/6] test(mcp): list_slos keeps every SLO's config when the status read fails --- .../src/mcp/tools/__tests__/slo-tools.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts index a4d71dbad3..7331ed6c75 100644 --- a/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/slo-tools.test.ts @@ -72,10 +72,13 @@ const fixtures: FixtureRule[] = [ }, ] +/** Set by one test to make the list's batched status reads fail as the warehouse would. */ +let failStatusReads = false + let member: EvalRuntime let admin: EvalRuntime beforeAll(() => { - installFakeWarehouse(fixtures) + installFakeWarehouse(fixtures, (sql) => failStatusReads && Str.includes("AS serviceName")(sql)) member = makeEvalRuntime() admin = { ...member, tenant: { ...member.tenant, roles: [Schema.decodeSync(RoleName)("org:admin")] } } }) @@ -195,6 +198,19 @@ describe("create_slo", () => { }) describe("list_slos", () => { + it("still lists every SLO's config when the status read fails, and says what to do next", async () => { + failStatusReads = true + const result = await runToolDirect(member, "list_slos", {}).finally(() => { + failStatusReads = false + }) + const output = decode(ListSlosOutput, result) + expect(output.slos.length).toBeGreaterThan(0) + for (const row of output.slos) { + expect(row).toMatchObject({ sli: null, budgetRemaining: null, burning: null }) + expect(row.statusError).toContain("inspect_slo") + } + }) + it("puts the burning SLO first with its SLI, budget left and worst burn", async () => { const result = await runToolDirect(member, "list_slos", {}) const output = decode(ListSlosOutput, result)