Repository navigation
feat(alerts): acknowledge alert incidents (core, API, MCP) #2287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
1568e48
feat(alerts): acknowledge alert incidents (core, API, MCP)
Makisuo 5a66d2b
fix(alerts): clear an ack on escalation in the claimed write, decided…
Makisuo 090cbf6
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo 836f27a
chore(ios): regenerate the OpenAPI spec for the incident acknowledgem…
Makisuo 7ec39de
fix(alerts): keep the acknowledged filter on the incident list's next…
Makisuo 9eaf37e
chore(domain): regenerate anticipated errors for AlertIncidentResolve…
Makisuo 6577df5
fix(alerts): leave the internal incident id out of the resolved-incid…
Makisuo 5abdc40
fix(alerts): attribute MCP acks like the audit log, stamp ack_cleared…
Makisuo d4b37ee
chore(alerts): yield before the call
Makisuo 6a09658
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo f93853b
Merge remote-tracking branch 'origin/main' into feat/alert-ack-core
Makisuo 0a0e10a
test(mcp): acknowledge by inc_ id and assert the rendered text
Makisuo 3924399
fix(alerts): use Arr.findFirst for the delivery log's connector lookup
Makisuo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
apps/ai/src/mcp/tools/acknowledge-alert-incident.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", () => { | ||
| 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], | ||
| ), | ||
|
Makisuo marked this conversation as resolved.
Resolved
|
||
| ) | ||
| assert.isNull(rows.rows[0]?.acknowledged_by_user_id) | ||
| }).pipe(Effect.provide(layerFor(testDb))) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ), | ||
| ], | ||
| }), | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.