Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
21dd679
feat(mcp): SLO tools: list, inspect, create, update, delete
Makisuo Nov 6, 2026
05b43ed
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
a8ba729
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
65c5451
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
6f3fff8
fix(mcp): SLO tools show the no-events hold and find SLOs by rule id
Makisuo Nov 6, 2026
fabf222
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
8d35e97
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
b74eb40
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
3fd253b
fix(mcp): update_slo clears a description with clear_description
Makisuo Nov 6, 2026
9a958bf
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
072b520
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
5cdf85b
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
5d2e6a5
perf(mcp): list_slos reads every status in one batch
Makisuo Nov 6, 2026
b0937bf
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
ace6908
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
2445345
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
d52bbd2
fix(mcp): list_slos logs a failed status read and tells the model wha…
Makisuo Nov 6, 2026
a31fe51
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
1340b35
test(mcp): list_slos keeps every SLO's config when the status read fails
Makisuo Nov 6, 2026
f955bfe
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
014926e
Merge branch 'feat/slo-burn-alerts' into feat/slo-mcp
Makisuo Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -292,6 +293,7 @@ const McpRuntimeServicesLive = Layer.mergeAll(
ScrapeTargetsService.layer,
SecurityAuditStore.layer,
SetupAuditService.layer,
SlosService.layer,
StatusPageSnapshotBuilder.layer,
StatusPagesService.layer,
UptimeMonitorsService.layer,
Expand Down
209 changes: 209 additions & 0 deletions apps/ai/src/mcp/lib/slos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/** 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,
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 { 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()

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,
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<SloBurnWindow>): "critical" | "warning" | null =>
Option.match(
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<SloBurnWindow>): 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>): 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) => {
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 =>
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<SloSli, McpInvalidInputError> => {
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_…`), 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 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 with the name or id "${ref}". list_slos lists them.`,
parameter: "slo",
})
}
return found.value
})

/**
* For burn-rate incidents: the SLOs behind them (their rules are hidden from list_alert_rules),
* 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 ruleId: string
readonly ruleName: string
}>,
): ReadonlyArray<NextCall> => {
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 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. */
export const onlySloIncidents = (incidents: ReadonlyArray<{ readonly signalType: string }>): boolean =>
incidents.length > 0 && Arr.every(incidents, (incident) => incident.signalType === "slo_burn_rate")
1 change: 1 addition & 0 deletions apps/ai/src/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<its alert rule>. 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
Expand Down
Loading
Loading