Skip to content
Open
13 changes: 10 additions & 3 deletions apps/ai/src/mcp/__evals__/eval-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ConfigProvider, Effect, Layer, ManagedRuntime, Schema } from "effect"
import { McpServicesLive } from "../../runtime/mcp-service-graph"
import { mcpServicesWithFeatureFlags } from "../../runtime/mcp-service-graph"
import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService"
import { Env } from "@maple/backend/platform/Env"
import { WorkerEnvironment } from "@maple/infra/worker-runtime"
import { createTestDb, type TestDb } from "@maple/backend/platform/test-pglite"
Expand Down Expand Up @@ -43,7 +44,13 @@ export interface EvalRuntime {
* carries the already-resolved MCP tenant, matching the post-auth dispatcher
* context shared by HTTP and RPC.
*/
export const makeEvalRuntime = (): EvalRuntime => {
export const makeEvalRuntime = (
featureFlags: Layer.Layer<
OrganizationFeatureFlagsService,
never,
Env
> = OrganizationFeatureFlagsService.layer,
): EvalRuntime => {
const testDb = createTestDb()
const env = testEnv()

Expand All @@ -52,7 +59,7 @@ export const makeEvalRuntime = (): EvalRuntime => {
const databaseLive = testDb.layer
const workerEnvLive = Layer.succeed(WorkerEnvironment, env as Record<string, unknown>)

const layer = McpServicesLive.pipe(
const layer = mcpServicesWithFeatureFlags(featureFlags).pipe(
Layer.provide(Layer.mergeAll(configLive, envLive, databaseLive, workerEnvLive)),
)
// `as any`: the residual requirement set is satisfied at runtime (same pattern
Expand Down
66 changes: 66 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
ListStatusPagesOutput,
StatusPageIncidentMutationOutput,
} from "@maple/domain/mcp-outputs"
import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags"
import { executeSql } from "@maple/backend/platform/test-pglite"
import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService"
import type { McpToolResult } from "../types"
import { makeEvalRuntime, markdown, runToolDirect, type EvalRuntime } from "../../__evals__/eval-runtime"

Expand Down Expand Up @@ -117,6 +119,26 @@ describe("status page tools", () => {
}),
)
expect(created.incident.published).toBe(true)
expect(created.pageDraft).toBe(false)
})

it("warns that a published incident stays private while its page is a draft", async () => {
await executeSql(
rt.testDb,
`INSERT INTO status_pages (id, org_id, name, slug, draft, created_by, created_at, updated_at)
VALUES ($1, $2, 'Draft Status', 'draft-status', true, $3, now(), now())`,
["7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d0a", rt.tenant.orgId, rt.tenant.userId],
)
const result = await runToolDirect(rt, "create_status_page_incident", {
page: "draft-status",
title: "Login delays",
message: "Some sign-ins are slow.",
publish: true,
})
const created = decode(StatusPageIncidentMutationOutput, result)
expect(created.incident.published).toBe(true)
expect(created.pageDraft).toBe(true)
expect(markdown(result)).toContain("The page is a draft")
})

it("schedules maintenance as a draft with its window", async () => {
Expand Down Expand Up @@ -400,3 +422,47 @@ describe("status page tools", () => {
})
})
})

describe("status page tools outside the statusPages rollout", () => {
let off: EvalRuntime
beforeAll(() => {
off = makeEvalRuntime(OrganizationFeatureFlagsService.fixed(DISABLED_ORGANIZATION_FEATURE_FLAGS))
})
afterAll(async () => {
await off.dispose()
})

it("refuses every tool as unavailable, naming the missing capability", async () => {
// A real document, so the apply is refused by the gate and not by decoding.
const config = decode(
GetStatusPageConfigOutput,
await runToolDirect(rt, "get_status_page_config", { page: "acme-status" }),
).config
const incident = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d09"
const calls: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
["list_status_pages", {}],
["get_status_page", { page: "acme-status" }],
["create_status_page_incident", { page: "acme-status", title: "x", message: "y" }],
["post_status_page_update", { incident, status: "identified", message: "y" }],
["resolve_status_page_incident", { incident, message: "y" }],
[
"schedule_status_page_maintenance",
{
page: "acme-status",
title: "x",
message: "y",
starts_at: "2026-08-01T02:00:00Z",
ends_at: "2026-08-01T03:00:00Z",
},
],
["get_status_page_config", { page: "acme-status" }],
["apply_status_page_config", { page: "acme-status", config }],
]
for (const [name, params] of calls) {
const result: McpToolResult = await runToolDirect(off, name, params)
expect(result.isError).toBe(true)
expect(result.failureCategory).toBe("unavailable")
expect(markdown(result)).toContain("not enabled for this organization")
}
})
})
2 changes: 2 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,7 @@ 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 { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService"
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"
Expand Down Expand Up @@ -55,6 +56,7 @@ export type McpToolRuntimeRequirements =
| SetupAuditService
| StatusPageSnapshotBuilder
| StatusPagesService
| OrganizationFeatureFlagsService
| UptimeMonitorsService
| IngestRuleService
| VcsCommitService
Expand Down
118 changes: 96 additions & 22 deletions apps/ai/src/mcp/tools/status-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@ import {
StatusPageIncidentImpact,
type OrgId,
type StatusPageComponentId,
type StatusPageId,
type StatusPageIncidentStatus,
} from "@maple/domain/primitives"
import { isActiveIncident, overallStatus, pageFreshness } from "@maple/domain/status-page"
import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine"
import { AuditLogService } from "@maple/backend/services/audit/AuditLogService"
import { auditAttribution } from "@maple/backend/services/audit/audit-access"
import { auditAttribution, originMetadata } from "@maple/backend/services/audit/audit-access"
import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor"
import { requireAdmin } from "@maple/backend/services/auth/auth"
import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService"
import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService"
import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder"
import { McpInvalidInputError, McpQueryError, McpUnavailableError, type McpToolRegistrar } from "./types"
Expand Down Expand Up @@ -123,6 +125,59 @@ const toIncidentRow = (incident: StatusPageIncidentRecord): typeof StatusPageInc
}
}

/** The caller's tenant, once its org is in the `statusPages` rollout. */
const tenantInRollout = Effect.fnUntraced(function* (tool: string) {
const tenant = yield* CurrentMcpTenant
const flags = yield* (yield* OrganizationFeatureFlagsService).flags(tenant.orgId)
if (!flags.statusPages) {
return yield* new McpUnavailableError({
message: `${tool} needs status pages, which are not enabled for this organization.`,
capability: "status_pages",
})
}
return tenant
})

/** The same resource entries the REST routes write, so MCP edits show up per incident. */
const auditIncidentCreated = Effect.fnUntraced(function* (incident: StatusPageIncidentRecord) {
const tenant = yield* CurrentMcpTenant
yield* (yield* AuditLogService).record({
orgId: tenant.orgId,
...auditAttribution(tenant, yield* CurrentAuditActor),
action: "status_page_incident.created",
resourceId: incident.id,
metadata: {
Comment thread
Makisuo marked this conversation as resolved.
Resolved
title: incident.title,
kind: incident.kind,
page_id: incident.pageId,
published: incident.published,
...originMetadata(tenant.turnOrigin),
},
})
})

const auditUpdatePosted = Effect.fnUntraced(function* (incident: StatusPageIncidentRecord) {
const tenant = yield* CurrentMcpTenant
yield* (yield* AuditLogService).record({
orgId: tenant.orgId,
...auditAttribution(tenant, yield* CurrentAuditActor),
action: "status_page_incident.update_posted",
resourceId: incident.id,
metadata: {
status: incident.status,
page_id: incident.pageId,
active: isActiveIncident(incident),
...originMetadata(tenant.turnOrigin),
},
})
})

/** Whether the incident's page is still a draft, which keeps everything on it private. */
const pageIsDraft = Effect.fnUntraced(function* (tool: string, orgId: OrgId, pageId: StatusPageId) {
const page = yield* (yield* StatusPagesService).get(orgId, pageId).pipe(Effect.mapError(toMcpError(tool)))
return page.draft
})

/** A page by its id, REST id (`stp_…`), slug or exact name. */
const resolvePage = Effect.fnUntraced(function* (tool: string, orgId: OrgId, ref: string) {
const service = yield* StatusPagesService
Expand Down Expand Up @@ -193,14 +248,18 @@ const renderIncident = (title: string, output: typeof StatusPageIncidentMutation
const incident = output.incident
return {
title,
notices: incident.published
? []
: incident.resolvedAt === null
? [
"This is a draft: it is not on the public page. post_status_page_update with publish=true publishes it.",
]
: // A closed draft must not be pointed at post_status_page_update: its statuses reopen it.
["This was closed as a draft, so it never appeared on the public page."],
notices: output.pageDraft
? [
"The page is a draft, so nothing on it is public, this incident included. Publish the page in Maple under Status pages.",
]
: incident.published
? []
: incident.resolvedAt === null
? [
"This is a draft: it is not on the public page. post_status_page_update with publish=true publishes it.",
]
: // A closed draft must not be pointed at post_status_page_update: its statuses reopen it.
["This was closed as a draft, so it never appeared on the public page."],
blocks: [
doc.fields([
["Title", incident.title],
Expand Down Expand Up @@ -243,7 +302,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
hints: { readOnly: true, destructive: false, idempotent: true },
phrases: ["Checking status pages"],
handler: Effect.fn("McpTool.listStatusPages")(function* () {
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout("list_status_pages")
const service = yield* StatusPagesService
const pages = yield* service
.list(tenant.orgId)
Expand Down Expand Up @@ -306,7 +365,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
hints: { readOnly: true, destructive: false, idempotent: true },
phrases: ["Reading a status page"],
handler: Effect.fn("McpTool.getStatusPage")(function* (params) {
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout("get_status_page")
const service = yield* StatusPagesService
const builder = yield* StatusPageSnapshotBuilder
const page = yield* resolvePage("get_status_page", tenant.orgId, params.page)
Expand Down Expand Up @@ -446,7 +505,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Opening a status page incident"],
handler: Effect.fn("McpTool.createStatusPageIncident")(function* (params) {
const tool = "create_status_page_incident"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
const service = yield* StatusPagesService
const page = yield* resolvePage(tool, tenant.orgId, params.page)
const components = yield* pageComponents(tool, tenant.orgId, page.id)
Expand All @@ -464,7 +523,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
createdBy: tenant.userId,
})
.pipe(Effect.mapError(toMcpError(tool)))
return { action: "created" as const, incident: toIncidentRow(created) }
yield* auditIncidentCreated(created)
return { action: "created" as const, incident: toIncidentRow(created), pageDraft: page.draft }
}),
render: (output) => renderIncident("Status page incident opened", output),
})
Expand All @@ -490,7 +550,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Posting a status page update"],
handler: Effect.fn("McpTool.postStatusPageUpdate")(function* (params) {
const tool = "post_status_page_update"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
const service = yield* StatusPagesService
const incidentId = yield* decodeIncidentId(params.incident)
const current = yield* service
Expand All @@ -515,7 +575,12 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
publish: params.publish === true,
})
.pipe(Effect.mapError(toMcpError(tool)))
return { action: "updated" as const, incident: toIncidentRow(posted) }
yield* auditUpdatePosted(posted)
return {
action: "updated" as const,
incident: toIncidentRow(posted),
pageDraft: yield* pageIsDraft(tool, tenant.orgId, posted.pageId),
}
}),
render: (output) => renderIncident("Status page update posted", output),
})
Expand All @@ -535,7 +600,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Resolving a status page incident"],
handler: Effect.fn("McpTool.resolveStatusPageIncident")(function* (params) {
const tool = "resolve_status_page_incident"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
const service = yield* StatusPagesService
const incidentId = yield* decodeIncidentId(params.incident)
const current = yield* service
Expand All @@ -551,7 +616,12 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
publish: params.publish === true,
})
.pipe(Effect.mapError(toMcpError(tool)))
return { action: "resolved" as const, incident: toIncidentRow(updated) }
yield* auditUpdatePosted(updated)
return {
action: "resolved" as const,
incident: toIncidentRow(updated),
pageDraft: yield* pageIsDraft(tool, tenant.orgId, updated.pageId),
}
}),
render: (output) => renderIncident("Status page incident resolved", output),
})
Expand All @@ -576,7 +646,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Scheduling maintenance"],
handler: Effect.fn("McpTool.scheduleStatusPageMaintenance")(function* (params) {
const tool = "schedule_status_page_maintenance"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
const service = yield* StatusPagesService
const page = yield* resolvePage(tool, tenant.orgId, params.page)
const components = yield* pageComponents(tool, tenant.orgId, page.id)
Expand All @@ -596,7 +666,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
createdBy: tenant.userId,
})
.pipe(Effect.mapError(toMcpError(tool)))
return { action: "scheduled" as const, incident: toIncidentRow(created) }
yield* auditIncidentCreated(created)
return { action: "scheduled" as const, incident: toIncidentRow(created), pageDraft: page.draft }
}),
render: (output) => renderIncident("Maintenance scheduled", output),
})
Expand All @@ -614,7 +685,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Reading a status page config"],
handler: Effect.fn("McpTool.getStatusPageConfig")(function* (params) {
const tool = "get_status_page_config"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
const service = yield* StatusPagesService
const page = yield* resolvePage(tool, tenant.orgId, params.page)
const config = yield* service
Expand Down Expand Up @@ -652,7 +723,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
phrases: ["Applying a status page config"],
handler: Effect.fn("McpTool.applyStatusPageConfig")(function* (params) {
const tool = "apply_status_page_config"
const tenant = yield* CurrentMcpTenant
const tenant = yield* tenantInRollout(tool)
yield* requireAdmin(
tenant.roles,
() =>
Expand Down Expand Up @@ -683,6 +754,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
components_updated: changes.updated.length,
components_deleted: changes.deleted.length,
components_reordered: changes.reordered,
...originMetadata(tenant.turnOrigin),
},
})
const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool)))
Expand All @@ -701,7 +773,9 @@ export function registerStatusPageTools(server: McpToolRegistrar) {
title: `Status page config applied: ${output.page.name}`,
notices: unchanged
? ["Nothing changed: the document matched the page."]
: ["The change publishes to the public page within a minute."],
: output.page.draft
? ["The page is a draft, so the change stays private until the page is published."]
: ["The change publishes to the public page within a minute."],
blocks: [
doc.fields([
["Fields changed", list(fields)],
Expand Down
Loading
Loading