Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/ai/src/mcp/lib/alert-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ export const renderRuleWrite = (
],
next: [
doc.next("get_alert_rule", { rule_id: rule.id }, "full configuration"),
doc.next(
"preview_alert_rule",
{ rule_id: rule.id },
"replay it over the last day to see what it would have done",
),
doc.next("list_alert_checks", { rule_id: rule.id }, "its evaluations once the scheduler picks it up"),
],
})
2 changes: 1 addition & 1 deletion apps/ai/src/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\`
- Performance analysis: find_slow_traces -> inspect_trace -> get_service_top_operations
- Trend analysis: query_data (timeseries or breakdown)
- Service discovery: list_services -> diagnose_service
- Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents
- 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
- AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session -> inspect_span (decodes an AI span's messages and tool calls)
- Agent tool health: get_agent_tools_overview -> get_agent_tools_overview tool="<tool>" (its failure groups) -> get_agent_tool_error
- Product analytics / conversion: list_product_events -> query_funnel (steps over page views, \`track()\` events and server events, stitched per person; \`breakdown_by\` a UTM/referrer dimension or an event attribute) -> add_dashboard_widget with \`panel_type: "funnel"\` and \`display_json.funnel.steps\` to pin it (\`display_json.funnel.variant: "dropoff"\` for the step-by-step drop-off view), or \`panel_type: "paths"\` with \`display_json.paths.anchor\` for what people do after / before one event
Expand Down
75 changes: 75 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/alert-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
AlertIncidentsListResponse,
AlertRuleDocument,
AlertRuleNotFoundError,
AlertRulePreviewRequest,
AlertRulePreviewResponse,
AlertRulesListResponse,
AlertRuleUpsertRequest,
OrgId,
Expand All @@ -22,6 +24,7 @@ import {
ListAlertDestinationsOutput,
ListAlertIncidentsOutput,
ListAlertRulesOutput,
PreviewAlertRuleOutput,
} from "@maple/domain/mcp-outputs"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"
import { AlertsService } from "@maple/backend/services/alerts/AlertsService"
Expand Down Expand Up @@ -99,6 +102,7 @@ const incident = (overrides: Record<string, unknown>) => ({
interface Seen {
created?: AlertRuleUpsertRequest
updated?: AlertRuleUpsertRequest
previewed?: AlertRulePreviewRequest
incidentOptions?: unknown
checkOptions?: unknown
}
Expand Down Expand Up @@ -134,6 +138,36 @@ const layer = (seen: Seen) =>
),
} as never),
Layer.succeed(AlertsService, {
previewRule: (_o: unknown, _r: unknown, request: AlertRulePreviewRequest) => {
seen.previewed = request
const point = (bucket: string) => ({
bucket,
value: null,
sampleCount: 0,
status: "skipped",
skipReason: "no_data",
})
return Effect.succeed(
Schema.decodeUnknownSync(AlertRulePreviewResponse)({
bucketSeconds: 300,
windowMinutes: 5,
threshold: 0.05,
thresholdUpper: null,
comparator: "gt",
truncatedToStart: null,
series: [
{
groupKey: "__total__",
points: [
point("2026-09-24T09:50:00.000Z"),
point("2026-09-24T09:55:00.000Z"),
],
},
],
wouldFire: [],
}),
)
},
updateRule: (
_o: unknown,
_u: unknown,
Expand Down Expand Up @@ -457,6 +491,47 @@ describe("alert tools", () => {
).toBeUndefined()
})

it("preview_alert_rule replays a draft and flags a window range with no data", async () => {
const seen: Seen = {}
const result = await ok(
"preview_alert_rule",
{
signal_type: "raw_query",
comparator: "gt",
threshold: 0.05,
raw_query_sql:
"SELECT count() AS value FROM traces WHERE $__orgFilter AND $__timeFilter(Timestamp)",
start_time: "2026-09-24 09:00:00",
end_time: "2026-09-24 10:00:00",
},
seen,
)
expect(seen.previewed?.rule).toMatchObject({
name: "Preview",
destinationIds: [],
signalType: "raw_query",
})
expect(seen.previewed?.startTime).toBe("2026-09-24T09:00:00Z")
const output = Schema.decodeUnknownSync(PreviewAlertRuleOutput)(result.structuredContent)
expect(output.groups[0]).toMatchObject({ windows: 2, noData: 2, breached: 0 })
expect(output.points[0]?.skipReason).toBe("no_data")
expect(output.warnings[0]).toContain("Every window had no data")
expect(text(result)).toContain("| no data |")
})

it("preview_alert_rule overlays overrides on a saved rule", async () => {
const seen: Seen = {}
await ok("preview_alert_rule", { rule_id: RULE_ID, threshold: 0.2 }, seen)
expect(seen.previewed?.rule).toMatchObject({
name: "Checkout errors",
threshold: 0.2,
signalType: "error_rate",
})

const missing = await run("preview_alert_rule", { rule_id: "8f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f" })
expect(missing._tag === "Failure" && missing.failure.parameter).toBe("rule_id")
})

it("update_alert_rule overlays only the given fields", async () => {
const seen: Seen = {}
await ok(
Expand Down
8 changes: 5 additions & 3 deletions apps/ai/src/mcp/tools/create-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const ALERT_TEMPLATES = {
throughput_drop: { signalType: "throughput", comparator: "lt", defaultThreshold: 100 },
} satisfies Record<TemplateName, AlertTemplate>

const Parameters = Schema.Struct({
export const CreateAlertRuleParameters = Schema.Struct({
name: P.text("Rule name"),
destination_ids: P.list(
"Destination IDs to notify (use list_alert_destinations to find IDs). Pass an empty list for none.",
Expand Down Expand Up @@ -139,7 +139,9 @@ const invalid = (message: string, parameter: string, example?: string) =>
)

/** The upsert request the params describe, before the domain schema checks it. */
const buildAlertRuleRequest = Effect.fnUntraced(function* (params: typeof Parameters.Type) {
export const buildAlertRuleRequest = Effect.fnUntraced(function* (
params: typeof CreateAlertRuleParameters.Type,
) {
const template: AlertTemplate | undefined =
params.template === undefined || params.template === "custom"
? undefined
Expand Down Expand Up @@ -272,7 +274,7 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) {
description:
"Create an alert rule. Pick a `template` for the common cases; otherwise pass signal_type, comparator and threshold yourself. " +
"Use list_alert_destinations for destination_ids.",
parameters: Parameters,
parameters: CreateAlertRuleParameters,
aliases: { service_names: "services" },
output: CreateAlertRuleOutput,
hints: { readOnly: false, destructive: false, idempotent: false },
Expand Down
5 changes: 5 additions & 0 deletions apps/ai/src/mcp/tools/get-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
"recent evaluations: observed values and near-misses",
),
doc.next("get_incident_timeline", { rule_id: rule.id }, "incident history for this rule"),
doc.next(
"preview_alert_rule",
{ rule_id: rule.id },
"replay it over past data, with or without changes",
),
],
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/ai/src/mcp/tools/list-alert-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ export function registerListAlertChecksTool(server: McpToolRegistrar) {
if (output.noData > 0 && output.noData === output.total) {
next.push(
doc.next(
"get_alert_rule",
"preview_alert_rule",
{ rule_id: output.ruleId },
"every check had no data (the query matched nothing), so the rule is blind, not healthy: check its filters",
"every check had no data (the query matched nothing), so the rule is blind, not healthy: replay it with adjusted filters",
),
)
} else if (output.belowMinSamples > 0) {
Expand Down
Loading
Loading