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
2 changes: 2 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RepoSandboxService, type RepositoryTarget } from "@maple/backend/servic
import { AlertsService } from "@maple/backend/services/alerts/AlertsService"

import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService"

import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService"
Expand Down Expand Up @@ -270,6 +271,7 @@ const MAX_PREPARED_REPOSITORIES = 3
const McpRuntimeServicesLive = Layer.mergeAll(
AgentFeedbackService.layer,
AlertReadModelsService.layer,
AlertIncidentAckService.layer,
AlertRulesService.layer,
AlertsService.layer,
AuditLogService.layer,
Expand Down
9 changes: 9 additions & 0 deletions apps/ai/src/mcp/lib/alert-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AlertRuleId,
AlertSeverity,
AlertSignalType,
type AlertIncidentDocument,
type AlertRuleDocument,
type AlertRuleDestinationNotFoundError,
type AlertRuleNotFoundError,
Expand Down Expand Up @@ -286,3 +287,11 @@ export const renderRuleWrite = (
],
}
}

/** Who acknowledged an open incident, for the model; null when nobody has. */
export const ackLabel = (
incident: Pick<AlertIncidentDocument, "acknowledgedAt" | "acknowledgedByName" | "acknowledgedVia">,
): string | null =>
incident.acknowledgedAt === null
? null
: (incident.acknowledgedByName ?? `someone via ${incident.acknowledgedVia ?? "Maple"}`)
13 changes: 12 additions & 1 deletion apps/ai/src/mcp/lib/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import {
String as Str,
} from "effect"
import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2"
import { AlertDestinationId, AlertRuleId, DashboardId, ErrorIssueId } from "@maple/domain/primitives"
import {
AlertDestinationId,
AlertIncidentId,
AlertRuleId,
DashboardId,
ErrorIssueId,
} from "@maple/domain/primitives"
import { WarehouseTimeInput, type WarehouseDateTime } from "@maple/query-engine"
import { McpInvalidInputError } from "../tools/types"
import { resolveWindow } from "./time"
Expand Down Expand Up @@ -141,6 +147,11 @@ export const optionalList = (description: string) =>
/** The resources a tool takes ids for, with their REST public-id prefix and internal id schema. */
const RESOURCE_IDS = {
alertRule: { prefix: PublicIdPrefixes.alertRule, schema: AlertRuleId, noun: "alert rule" },
alertIncident: {
prefix: PublicIdPrefixes.alertIncident,
schema: AlertIncidentId,
noun: "alert incident",
},
alertDestination: {
prefix: PublicIdPrefixes.alertDestination,
schema: AlertDestinationId,
Expand Down
5 changes: 3 additions & 2 deletions apps/ai/src/mcp/tool-descriptors.generated.ts

Large diffs are not rendered by default.

4 changes: 4 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 @@ -135,6 +135,10 @@ const incident = (overrides: Record<string, unknown>) => ({
lastNotifiedAt: null,
holdReason: null,
heldSince: null,
acknowledgedAt: null,
acknowledgedByUserId: null,
acknowledgedByName: null,
acknowledgedVia: null,
errorIssueId: null,
...overrides,
})
Expand Down
176 changes: 176 additions & 0 deletions apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { afterEach, assert, describe, it } from "@effect/vitest"
import { ConfigProvider, Effect, Layer, Schema } from "effect"
import { OrgId, UserId } from "@maple/domain/http"
import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2"
import { Env } from "@maple/backend/platform/Env"
import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive"
import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { CurrentMcpTenant } from "../lib/query-warehouse"
import { registerAcknowledgeAlertIncidentTool } from "./acknowledge-alert-incident"
import { renderToolDoc } from "../lib/tool-doc"
import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor"
import type { McpToolError, McpToolRegistrar } from "./types"
import type { McpToolRequirements } from "./runtime-requirements"
import { unimplementedMcpToolServices } from "../../../test/mcp/unimplemented-tool-services"

const trackedDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(trackedDbs))

const ORG = "org_mcp_ack"
const OPEN = "00000000-0000-4000-8000-0000000000b1"
const RESOLVED = "00000000-0000-4000-8000-0000000000b2"

type Invoke = (
params: Readonly<Record<string, unknown>>,
) => Effect.Effect<unknown, McpToolError, McpToolRequirements>

/** Calls the tool's handler; with `rendered`, returns the text the model reads instead. */
const acknowledgeTool = (rendered = false): Invoke => {
let invoke: Invoke = () => Effect.die("unregistered")
const registrar: McpToolRegistrar = {
define: (spec) => {
invoke = (params) =>
Schema.decodeEffect(spec.parameters)(params).pipe(
Effect.orDie,
Effect.flatMap(spec.handler),
Effect.map((output) => (rendered ? renderToolDoc(spec.render(output)) : output)),
)
},
}
registerAcknowledgeAlertIncidentTool(registrar)
return (params) => invoke(params)
}

const layerFor = (testDb: TestDb) =>
Layer.mergeAll(
unimplementedMcpToolServices,
AlertIncidentAckService.layer,
Layer.succeed(CurrentMcpTenant, {
orgId: Schema.decodeSync(OrgId)(ORG),
userId: Schema.decodeSync(UserId)("user_mcp_ack"),
roles: [],
authMode: "self_hosted",
}),
).pipe(
Layer.provide(Layer.mergeAll(testDb.layer, EdgeCacheServiceLive)),
Layer.provideMerge(Env.layer),
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
TINYBIRD_HOST: "https://api.tinybird.co",
TINYBIRD_TOKEN: "test-token",
MAPLE_AUTH_MODE: "self_hosted",
MAPLE_ROOT_PASSWORD: "test-root-password",
MAPLE_DEFAULT_ORG_ID: "default",
MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"),
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret",
INTERNAL_SERVICE_TOKEN: "test-internal-token",
}),
),
),
)

const seed = (testDb: TestDb) =>
Effect.promise(() =>
executeSql(
testDb,
`INSERT INTO alert_incidents
(id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status,
comparator, threshold, first_triggered_at, last_triggered_at, resolved_at,
dedupe_key, created_at, updated_at)
VALUES
($1, $3, '00000000-0000-4000-8000-0000000000b0', 'mcp-open', 'Checkout errors', '__total__',
'error_rate', 'critical', 'open', 'gt', 5, now(), now(), null, 'm-open', now(), now()),
($2, $3, '00000000-0000-4000-8000-0000000000b0', 'mcp-res', 'Checkout errors', '__total__',
'error_rate', 'critical', 'resolved', 'gt', 5, now(), now(), now(), 'm-res', now(), now())`,
[OPEN, RESOLVED, ORG],
),
)

describe("acknowledge_alert_incident", () => {
it.effect("acknowledges by REST id, unacknowledges, and reports a no-op", () => {
Comment thread
Makisuo marked this conversation as resolved.
Resolved
const testDb = createTestDb(trackedDbs)
const call = acknowledgeTool()
return Effect.gen(function* () {
yield* seed(testDb)
// The `inc_…` id the v2 API and the other tools print, not the internal UUID.
const acked = yield* call({ incident_id: encodePublicId(PublicIdPrefixes.alertIncident, OPEN) })
assert.deepInclude(acked, {
id: OPEN,
status: "open",
acknowledged: true,
changed: true,
// MCP has no member directory: the source stands in for the name.
acknowledgedBy: "someone via mcp",
})
assert.deepInclude(yield* call({ incident_id: OPEN, acknowledged: "true" }), { changed: false })
assert.deepInclude(yield* call({ incident_id: OPEN, acknowledged: false }), {
acknowledged: false,
acknowledgedBy: null,
changed: true,
})
}).pipe(Effect.provide(layerFor(testDb)))
})

it.effect("renders the change, a no-op notice, and the timeline as the next call", () => {
const testDb = createTestDb(trackedDbs)
const render = acknowledgeTool(true)
return Effect.gen(function* () {
yield* seed(testDb)
const acked = yield* render({ incident_id: OPEN })
assert.include(acked, "## Incident Acknowledged")
assert.include(acked, "Checkout errors")
assert.include(acked, "someone via mcp")
assert.include(acked, "get_incident_timeline")
assert.include(acked, "00000000-0000-4000-8000-0000000000b0")
assert.notInclude(acked, "nothing changed")
assert.include(yield* render({ incident_id: OPEN }), "Already acknowledged; nothing changed.")
const unacked = yield* render({ incident_id: OPEN, acknowledged: false })
assert.include(unacked, "## Incident Unacknowledged")
assert.notInclude(unacked, "someone via mcp")
}).pipe(Effect.provide(layerFor(testDb)))
})

it.effect("names a resolved or unknown incident as invalid input on incident_id", () => {
const testDb = createTestDb(trackedDbs)
const call = acknowledgeTool()
return Effect.gen(function* () {
yield* seed(testDb)
const resolved = yield* Effect.flip(call({ incident_id: RESOLVED }))
assert.deepInclude(resolved, {
_tag: "@maple/mcp/errors/McpInvalidInputError",
parameter: "incident_id",
})
const unknown = yield* Effect.flip(call({ incident_id: "00000000-0000-4000-8000-0000000000ff" }))
assert.deepInclude(unknown, {
_tag: "@maple/mcp/errors/McpInvalidInputError",
parameter: "incident_id",
})
const malformed = yield* Effect.flip(call({ incident_id: "dest_notAnIncident" }))
assert.deepInclude(malformed, {
_tag: "@maple/mcp/errors/McpInvalidInputError",
parameter: "incident_id",
})
}).pipe(Effect.provide(layerFor(testDb)))
})

it.effect("records Maple, not the token's placeholder user, when Maple itself acknowledges", () => {
const testDb = createTestDb(trackedDbs)
const call = acknowledgeTool()
return Effect.gen(function* () {
yield* seed(testDb)
const acked = yield* call({ incident_id: OPEN }).pipe(
Effect.provideService(CurrentAuditActor, { type: "system", source: "system" }),
)
assert.deepInclude(acked, { acknowledged: true, acknowledgedBy: "Maple" })
const rows = yield* Effect.promise(() =>
testDb.pglite.query<{ acknowledged_by_user_id: string | null }>(
"select acknowledged_by_user_id from alert_incidents where id = $1",
[OPEN],
),
Comment thread
Makisuo marked this conversation as resolved.
Resolved
)
assert.isNull(rows.rows[0]?.acknowledged_by_user_id)
}).pipe(Effect.provide(layerFor(testDb)))
})
})
109 changes: 109 additions & 0 deletions apps/ai/src/mcp/tools/acknowledge-alert-incident.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./types"
import { Effect, Schema } from "effect"
import { AcknowledgeAlertIncidentOutput } from "@maple/domain/mcp-outputs"
import { AlertIncidentAckService } from "@maple/backend/services/alerts/AlertIncidentAckService"
import { auditAttribution } from "@maple/backend/services/audit/audit-access"
import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor"
import { CurrentMcpTenant } from "../lib/query-warehouse"
import { ackLabel } from "../lib/alert-rules"
import * as P from "../lib/params"
import { doc } from "../lib/tool-doc"

export function registerAcknowledgeAlertIncidentTool(server: McpToolRegistrar) {
server.define({
name: "acknowledge_alert_incident",
title: "Acknowledge Alert Incident",
description:
"Mark an open alert incident as acknowledged (someone is on it) or, with acknowledged=false, clear the acknowledgement. " +
"While acknowledged, reminders pause; an escalation to a higher severity still notifies and clears it. " +
"Only open incidents; ids from list_alert_incidents.",
parameters: Schema.Struct({
incident_id: P.resourceId("alertIncident", "Alert incident ID"),
acknowledged: P.optionalFlag("false to unacknowledge. Default true."),
}),
output: AcknowledgeAlertIncidentOutput,
hints: { readOnly: false, destructive: false, idempotent: true },
phrases: ["Acknowledging an alert incident"],
handler: Effect.fn("McpTool.acknowledgeAlertIncident")(function* (params) {
const incidentId = yield* P.decodeResourceId("alertIncident", "incident_id")(params.incident_id)
const tenant = yield* CurrentMcpTenant
const acks = yield* AlertIncidentAckService
const acknowledge = params.acknowledged !== false
// Attributed as the audit log attributes this call: Maple itself and a chat connector's
// placeholder carry no user, an agent is named by its client.
const credential = yield* CurrentAuditActor
const attribution = auditAttribution(tenant, credential)
const actor = {
userId: attribution.actor.userId ?? null,
name: attribution.actor.label ?? (attribution.actor.type === "system" ? "Maple" : null),
source: "mcp" as const,
}

const result = yield* (
acknowledge
? acks.acknowledge(tenant.orgId, incidentId, actor)
: acks.unacknowledge(tenant.orgId, incidentId, actor)
).pipe(
Effect.catchTags({
"@maple/http/errors/AlertIncidentNotFoundError": () =>
Effect.fail(
new McpInvalidInputError({
message: `No alert incident ${params.incident_id}. List incidents with list_alert_incidents.`,
parameter: "incident_id",
}),
),
"@maple/http/errors/AlertIncidentResolvedError": (error) =>
Effect.fail(
new McpInvalidInputError({ message: error.message, parameter: "incident_id" }),
),
"@maple/http/errors/AlertPersistenceError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "acknowledge_alert_incident",
cause: error,
}),
),
}),
)

const { incident } = result
return {
id: incident.id,
ruleId: incident.ruleId,
ruleName: incident.ruleName,
status: incident.status,
acknowledged: incident.acknowledgedAt !== null,
acknowledgedAt: incident.acknowledgedAt,
acknowledgedBy: ackLabel(incident),
changed: result.changed,
}
}),
render: (output) => ({
title: output.acknowledged ? "Incident Acknowledged" : "Incident Unacknowledged",
blocks: [
...(output.changed
? []
: [
doc.text(
`Already ${output.acknowledged ? "acknowledged" : "unacknowledged"}; nothing changed.`,
),
]),
doc.fields([
["Incident", output.id],
["Rule", output.ruleName],
["Status", output.status],
["Acknowledged by", output.acknowledgedBy ?? undefined],
["Acknowledged at", output.acknowledgedAt ?? undefined],
]),
],
next: [
doc.next(
"get_incident_timeline",
{ rule_id: output.ruleId },
"this rule's incident timeline",
),
],
}),
})
}
16 changes: 15 additions & 1 deletion apps/ai/src/mcp/tools/get-incident-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { Effect, Schema, Array as Arr, String as Str } from "effect"
import { GetIncidentTimelineOutput } from "@maple/domain/mcp-outputs"
import { CurrentMcpTenant } from "../lib/query-warehouse"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
import { ALERT_INCIDENT_STATUSES, ALERT_SEVERITIES, formatCondition, parseRuleId } from "../lib/alert-rules"
import {
ackLabel,
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"
Expand Down Expand Up @@ -83,6 +89,8 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) {
lastTriggeredAt: i.lastTriggeredAt,
resolvedAt: i.resolvedAt,
lastNotifiedAt: i.lastNotifiedAt,
acknowledgedAt: i.acknowledgedAt,
acknowledgedBy: ackLabel(i),
})),
total: incidents.length,
openCount: Arr.filter(incidents, (i) => i.status === "open").length,
Expand Down Expand Up @@ -121,6 +129,12 @@ export function registerGetIncidentTimelineTool(server: McpToolRegistrar) {
["Last triggered", formatTimestamp(inc.lastTriggeredAt)],
["Resolved at", formatTimestamp(inc.resolvedAt)],
["Last notified", formatTimestamp(inc.lastNotifiedAt)],
[
"Acknowledged",
inc.acknowledgedAt == null
? undefined
: `${formatTimestamp(inc.acknowledgedAt)} by ${inc.acknowledgedBy ?? "someone"}`,
],
["Incident ID", inc.id],
["Rule ID", inc.ruleId],
]),
Expand Down
Loading
Loading