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
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { RailwayMetricsService } from "@maple/backend/services/integrations/Rail
import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService"
import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService"
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"
import { VcsCommitService } from "@maple/backend/services/integrations/vcs/VcsCommitService"
import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService"
import { SourcesService } from "@maple/backend/services/org/SourcesService"
Expand Down Expand Up @@ -274,6 +276,8 @@ const McpRuntimeServicesLive = Layer.mergeAll(
RepoSandboxService.layer,
ScrapeTargetsService.layer,
SetupAuditService.layer,
StatusPageSnapshotBuilder.layer,
StatusPagesService.layer,
UptimeMonitorsService.layer,
IngestRuleService.layer,
VcsCommitService.layer,
Expand Down
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 @@ -116,6 +116,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.
- 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.
- 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)
- 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
227 changes: 227 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { Schema } from "effect"
import {
GetStatusPageOutput,
ListStatusPagesOutput,
StatusPageIncidentMutationOutput,
} from "@maple/domain/mcp-outputs"
import { executeSql } from "@maple/backend/platform/test-pglite"
import type { McpToolResult } from "../types"
import { makeEvalRuntime, markdown, runToolDirect, type EvalRuntime } from "../../__evals__/eval-runtime"

let rt: EvalRuntime
beforeAll(async () => {
rt = makeEvalRuntime()
const now = Date.now()
await executeSql(
rt.testDb,
`INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at)
VALUES ($1, $2, 'Acme Status', 'acme-status', $3, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`,
["7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d01", rt.tenant.orgId, rt.tenant.userId, now],
)
await executeSql(
rt.testDb,
`INSERT INTO status_page_components (id, org_id, page_id, name, position, created_at, updated_at)
VALUES ($1, $2, $3, 'API', 0, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`,
[
"7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d02",
rt.tenant.orgId,
"7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d01",
now,
],
)
})
afterAll(async () => {
await rt.dispose()
})

const decode = <S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, result: McpToolResult) =>
Schema.decodeUnknownSync(schema)(result.structuredContent)

describe("status page tools", () => {
it("lists the org's pages with their REST ids", async () => {
const output = decode(ListStatusPagesOutput, await runToolDirect(rt, "list_status_pages", {}))
expect(output.total).toBe(1)
expect(output.pages[0]).toMatchObject({ name: "Acme Status", slug: "acme-status" })
expect(output.pages[0]?.id).toMatch(/^stp_/)
})

it("opens an agent incident as a draft that the page does not show", async () => {
const created = decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "create_status_page_incident", {
page: "acme-status",
title: "Elevated API errors",
message: "We are looking into elevated error rates.",
components: ["API"],
impact: "major",
}),
)
expect(created.incident).toMatchObject({
kind: "incident",
status: "investigating",
published: false,
impact: "major",
})
expect(created.incident.id).toMatch(/^sti_/)

// A draft names the component but does not change what visitors see.
const page = decode(
GetStatusPageOutput,
await runToolDirect(rt, "get_status_page", { page: "acme-status" }),
)
expect(page.components).toMatchObject([{ name: "API", status: "operational" }])
expect(page.activeIncidents.map((i) => i.id)).toContain(created.incident.id)
expect(page.freshness.publishPending).toBe(true)

// Publishing with the next update puts it on the page.
const updated = decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "post_status_page_update", {
incident: created.incident.id,
status: "identified",
message: "We found the cause and are rolling back.",
publish: true,
}),
)
expect(updated.incident).toMatchObject({ status: "identified", published: true, updateCount: 2 })
const liveResult = await runToolDirect(rt, "get_status_page", { page: "Acme Status" })
// The follow-up needs public wording, so it is a notice, not a half-filled call.
expect(markdown(liveResult)).toContain("To update an open incident, call post_status_page_update")
const live = decode(GetStatusPageOutput, liveResult)
expect(live.components).toMatchObject([{ name: "API", status: "partial_outage" }])
expect(live.overallStatus).toBe("partial_outage")

const resolved = decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "resolve_status_page_incident", {
incident: created.incident.id,
message: "Error rates are back to normal.",
}),
)
expect(resolved.incident.status).toBe("resolved")
expect(resolved.incident.resolvedAt).not.toBeNull()
})

it("publishes straight away when asked", async () => {
const created = decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "create_status_page_incident", {
page: "acme-status",
title: "Login delays",
message: "Some sign-ins are slow.",
publish: true,
}),
)
expect(created.incident.published).toBe(true)
})

it("schedules maintenance as a draft with its window", async () => {
const result = await runToolDirect(rt, "schedule_status_page_maintenance", {
page: "acme-status",
title: "Database upgrade",
message: "The API may be read-only for a few minutes.",
starts_at: "2026-08-01 02:00:00",
ends_at: "2026-08-01T03:00:00Z",
components: ["API"],
})
const output = decode(StatusPageIncidentMutationOutput, result)
expect(output.incident).toMatchObject({
kind: "maintenance",
status: "scheduled",
published: false,
scheduledStart: "2026-08-01T02:00:00.000Z",
scheduledEnd: "2026-08-01T03:00:00.000Z",
})
expect(markdown(result)).toContain("draft")
})

it("names the unknown component and page instead of guessing", async () => {
const component = await runToolDirect(rt, "create_status_page_incident", {
page: "acme-status",
title: "x",
message: "y",
components: ["Billing"],
})
expect(component.isError).toBe(true)
expect(markdown(component)).toContain("API")
const page = await runToolDirect(rt, "get_status_page", { page: "nope" })
expect(page.isError).toBe(true)
expect(markdown(page)).toContain("list_status_pages")
})

it("resolves a draft and publishes it in the same call, or says it stayed a draft", async () => {
const open = async (title: string) =>
decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "create_status_page_incident", {
page: "acme-status",
title,
message: "Investigating.",
}),
).incident.id
const kept = await runToolDirect(rt, "resolve_status_page_incident", {
incident: await open("Quiet draft"),
message: "Never mind.",
})
expect(decode(StatusPageIncidentMutationOutput, kept).incident).toMatchObject({
status: "resolved",
published: false,
})
expect(markdown(kept)).toContain("closed as a draft")
expect(markdown(kept)).not.toContain("post_status_page_update")

const shown = decode(
StatusPageIncidentMutationOutput,
await runToolDirect(rt, "resolve_status_page_incident", {
incident: await open("Public draft"),
message: "Fixed.",
publish: true,
}),
)
expect(shown.incident).toMatchObject({ status: "resolved", published: true })
})

it("reads delayed while the page's monitors have not reported, and live once one has", async () => {
const monitorId = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d09"
const now = Date.now()
await executeSql(
rt.testDb,
`INSERT INTO uptime_monitors (id, org_id, name, kind, config_json, created_by, created_at, updated_at, enabled)
VALUES ($1, $2, 'API health', 'http', $3, $4, now(), now(), true)`,
[
monitorId,
rt.tenant.orgId,
JSON.stringify({
_tag: "http",
url: "https://example.com/health",
method: "GET",
headers: {},
assertions: [],
timeoutMs: 5000,
}),
rt.tenant.userId,
],
)
await executeSql(
rt.testDb,
`INSERT INTO uptime_monitor_states (monitor_id, org_id, status, recent_json, updated_at) VALUES ($1, $2, 'up', '[]', now())`,
[monitorId, rt.tenant.orgId],
)
await executeSql(rt.testDb, `UPDATE status_page_components SET monitor_id = $1 WHERE name = 'API'`, [
monitorId,
])
const freshness = async () =>
decode(GetStatusPageOutput, await runToolDirect(rt, "get_status_page", { page: "acme-status" }))
.freshness

expect(await freshness()).toMatchObject({ state: "delayed", updatedAt: null })
await executeSql(
rt.testDb,
`INSERT INTO uptime_monitor_daily (monitor_id, day, org_id, checks, failures, down_checks, last_check_at)
VALUES ($1, $2, $3, 1, 0, 0, to_timestamp($4 / 1000.0))`,
[monitorId, new Date(now).toISOString().slice(0, 10), rt.tenant.orgId, now - 30_000],
)
expect((await freshness()).state).toBe("live")
})
})
5 changes: 5 additions & 0 deletions apps/ai/src/mcp/tools/mutating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
"create_ingest_rule",
"update_ingest_rule",
"delete_ingest_rule",
// status pages: every post is public text on the customer's page
"create_status_page_incident",
"post_status_page_update",
"resolve_status_page_incident",
"schedule_status_page_maintenance",
// feedback
"send_maple_feedback",
])
2 changes: 2 additions & 0 deletions apps/ai/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { registerCompareCommitsTool } from "./compare-commits"
import { registerIntegrationWriteTools } from "./integration-writes"
import { registerUptimeMonitorTools } from "./uptime-monitors"
import { registerIngestRuleTools } from "./ingest-rules"
import { registerStatusPageTools } from "./status-pages"
import { registerGetInstrumentationRecommendationsTool } from "./get-instrumentation-recommendations"
import { registerGetServiceTopOperationsTool } from "./get-service-top-operations"
import { registerServiceDeploymentsTool } from "./service-deployments"
Expand Down Expand Up @@ -435,6 +436,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
registerIntegrationWriteTools(registrar)
registerUptimeMonitorTools(registrar)
registerIngestRuleTools(registrar)
registerStatusPageTools(registrar)
registerSourceCodeTools(registrar)
registerSandboxTools(registrar)
registerPullRequestTools(registrar)
Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/tools/runtime-requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type { VcsCommitService } from "@maple/backend/services/integrations/vcs/
import type { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService"
import type { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService"
import type { IngestRuleService } from "@maple/backend/services/org/IngestRuleService"
import type { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService"
import type { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder"
import type { SetupAuditService } from "@maple/backend/services/org/SetupAuditService"
import type { RepoSandboxService } from "@maple/backend/services/sandbox/RepoSandboxService"
import type { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService"
Expand Down Expand Up @@ -51,6 +53,8 @@ export type McpToolRuntimeRequirements =
| RepoSandboxService
| ScrapeTargetsService
| SetupAuditService
| StatusPageSnapshotBuilder
| StatusPagesService
| UptimeMonitorsService
| IngestRuleService
| VcsCommitService
Expand Down
Loading
Loading