diff --git a/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts new file mode 100644 index 0000000000..e91dde4d86 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts @@ -0,0 +1,94 @@ +/** + * Tools whose data sits outside the families every MCP key must already read ask for that family's + * scope, as the matching `/v2/` routes do. + */ +import { describe, expect, it } from "vitest" +import { OrgId, RoleName, UserId } from "@maple/domain/http" +import { INVESTIGATION_READ_FAMILIES } from "@maple/domain/http/v2" +import { Context, Effect, Layer, Schema, Array as Arr } from "effect" +import { CurrentMcpTenant } from "../../tenant" +import { executeRegisteredMcpToolUnscoped } from "../registry" + +const tenantWith = (scopes: ReadonlyArray) => + Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeSync(OrgId)("org_scope_families"), + userId: Schema.decodeSync(UserId)("user_scope_families"), + roles: [Schema.decodeSync(RoleName)("org:admin")], + authMode: "self_hosted", + scopes, + }) + +// A refused call stops at the registry, so none of the tools' services is ever reached. +const withoutUnreachedServices = (effect: Effect.Effect): Effect.Effect => + Effect.provide(effect, Context.makeUnsafe(new Map())) + +const call = (name: string, scopes: ReadonlyArray) => + Effect.runPromise( + executeRegisteredMcpToolUnscoped(name, {}, "mcp").pipe( + Effect.provide(tenantWith(scopes)), + Effect.result, + withoutUnreachedServices, + ), + ) + +/** A key that reads and writes every family the MCP server requires, and nothing beyond them. */ +const BASELINE = Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:write`) + +const REQUIRED = [ + ["list_slos", "slos:read"], + ["inspect_slo", "slos:read"], + ["create_slo", "slos:write"], + ["update_slo", "slos:write"], + ["delete_slo", "slos:write"], + ["list_uptime_monitors", "uptime_monitors:read"], + ["create_uptime_monitor", "uptime_monitors:write"], + ["update_uptime_monitor", "uptime_monitors:write"], + ["delete_uptime_monitor", "uptime_monitors:write"], + ["list_ingest_rules", "ingest_rules:read"], + ["preview_ingest_rule", "ingest_rules:read"], + ["create_ingest_rule", "ingest_rules:write"], + ["update_ingest_rule", "ingest_rules:write"], + ["delete_ingest_rule", "ingest_rules:write"], + ["list_data_sources", "data_sources:read"], + ["search_data_sources", "data_sources:read"], + ["describe_data_source", "data_sources:read"], + ["query_data_source", "data_sources:read"], + ["get_status_page", "status_pages:read"], + ["apply_status_page_config", "status_pages:write"], + ["post_status_page_update", "status_page_incidents:write"], + ["create_scrape_target", "scrape_targets:write"], + ["update_scrape_target", "scrape_targets:write"], + ["delete_scrape_target", "scrape_targets:write"], +] as const + +describe("MCP tools outside the baseline scope families", () => { + it("refuse a scoped key that lacks their family, naming the scope to add", async () => { + const results = await Promise.all( + Arr.map(REQUIRED, async ([name, scope]) => ({ name, scope, result: await call(name, BASELINE) })), + ) + Arr.forEach(results, ({ name, scope, result }) => { + expect(result._tag, name).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "@maple/mcp/errors/McpAuthInvalidError", + reason: "insufficient_scope", + }) + expect(result.failure.message).toContain(`\`${scope}\``) + } + }) + }) + + it("ask for every family a tool reads", async () => { + const result = await call("get_status_page", [...BASELINE, "status_pages:read"]) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") { + expect(result.failure.message).toContain("`status_page_incidents:read`") + } + }) + + it("refuse a read scope on a tool that changes data", async () => { + const result = await call("create_slo", [...BASELINE, "slos:read"]) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") expect(result.failure.message).toContain("`slos:write`") + }) +}) diff --git a/apps/ai/src/mcp/tools/describe-data-source.ts b/apps/ai/src/mcp/tools/describe-data-source.ts index b8f39c4e20..50f1387f0d 100644 --- a/apps/ai/src/mcp/tools/describe-data-source.ts +++ b/apps/ai/src/mcp/tools/describe-data-source.ts @@ -66,6 +66,7 @@ export function registerDescribeDataSourceTool(server: McpToolRegistrar) { }), aliases: { source: "data_source", name: "data_source", tables: "entries", resources: "entries" }, hints: { readOnly: true }, + scopeFamily: "data_sources", phrases: ["Reading a data source's catalog", "Describing a data source"], handler: Effect.fn("McpTool.describeDataSource")(function* ({ data_source, entries, offset }) { const tenant = yield* tenantWithDataSources(TOOL) diff --git a/apps/ai/src/mcp/tools/ingest-rules.ts b/apps/ai/src/mcp/tools/ingest-rules.ts index 9eb34ae9fd..ca767c1791 100644 --- a/apps/ai/src/mcp/tools/ingest-rules.ts +++ b/apps/ai/src/mcp/tools/ingest-rules.ts @@ -299,6 +299,7 @@ export function registerIngestRuleTools(server: McpToolRegistrar) { "The organization's ingest rules: what telemetry is dropped or redacted before Maple stores or bills it, in the order they run (drop rules first, then redact rules by position).", parameters: Schema.Struct({}), hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "ingest_rules", phrases: ["Listing ingest rules"], handler: Effect.fn("McpTool.listIngestRules")(function* () { const tenant = yield* CurrentMcpTenant @@ -359,6 +360,7 @@ export function registerIngestRuleTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "ingest_rules", phrases: ["Previewing an ingest rule"], handler: Effect.fn("McpTool.previewIngestRule")(function* (params) { const tenant = yield* CurrentMcpTenant @@ -455,6 +457,7 @@ export function registerIngestRuleTools(server: McpToolRegistrar) { position: P.optionalNumber("Run order among redact rules; default after the last rule"), }), hints: { readOnly: false, destructive: false, idempotent: false }, + scopeFamily: "ingest_rules", phrases: ["Adding an ingest rule"], handler: Effect.fn("McpTool.createIngestRule")(function* (params) { const tenant = yield* adminOnly("create_ingest_rule") @@ -491,6 +494,7 @@ export function registerIngestRuleTools(server: McpToolRegistrar) { action: P.optionalJson(V2IngestRuleAction, `Replaces the action: ${actionDescription}`), }), hints: { readOnly: false, destructive: false, idempotent: true }, + scopeFamily: "ingest_rules", phrases: ["Updating an ingest rule"], handler: Effect.fn("McpTool.updateIngestRule")(function* (params) { const tenant = yield* adminOnly("update_ingest_rule") @@ -533,6 +537,7 @@ export function registerIngestRuleTools(server: McpToolRegistrar) { confirm: P.flag("Must be true"), }), hints: { readOnly: false, destructive: true, idempotent: false }, + scopeFamily: "ingest_rules", phrases: ["Deleting an ingest rule"], handler: Effect.fn("McpTool.deleteIngestRule")(function* (params) { if (params.confirm !== true) { diff --git a/apps/ai/src/mcp/tools/integration-writes.ts b/apps/ai/src/mcp/tools/integration-writes.ts index 9a386c597f..caffb9f4c6 100644 --- a/apps/ai/src/mcp/tools/integration-writes.ts +++ b/apps/ai/src/mcp/tools/integration-writes.ts @@ -346,6 +346,7 @@ export function registerIntegrationWriteTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: false, destructive: false, idempotent: false, openWorld: true }, + scopeFamily: "scrape_targets", phrases: ["Adding a scrape target"], handler: Effect.fn("McpTool.createScrapeTarget")(function* (params) { const tenant = yield* permitted("create_scrape_target", "scrape_targets:manage") @@ -414,6 +415,7 @@ export function registerIntegrationWriteTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: false, destructive: false, idempotent: true, openWorld: true }, + scopeFamily: "scrape_targets", phrases: ["Updating a scrape target"], handler: Effect.fn("McpTool.updateScrapeTarget")(function* (params) { const tenant = yield* permitted("update_scrape_target", "scrape_targets:manage") @@ -468,6 +470,7 @@ export function registerIntegrationWriteTools(server: McpToolRegistrar) { confirm: P.flag("Must be true. There is no undo."), }), hints: { readOnly: false, destructive: true, idempotent: false }, + scopeFamily: "scrape_targets", phrases: ["Deleting a scrape target"], handler: Effect.fn("McpTool.deleteScrapeTarget")(function* (params) { if (params.confirm !== true) { diff --git a/apps/ai/src/mcp/tools/list-data-sources.ts b/apps/ai/src/mcp/tools/list-data-sources.ts index cc79e66d2e..a50b5c45d0 100644 --- a/apps/ai/src/mcp/tools/list-data-sources.ts +++ b/apps/ai/src/mcp/tools/list-data-sources.ts @@ -42,6 +42,7 @@ export function registerListDataSourcesTool(server: McpToolRegistrar) { offset: P.offset({ max: 10_000 }), }), hints: { readOnly: true }, + scopeFamily: "data_sources", phrases: ["Listing data sources", "Checking connected data sources"], handler: Effect.fn("McpTool.listDataSources")(function* ({ offset }) { const tenant = yield* tenantWithDataSources(TOOL) diff --git a/apps/ai/src/mcp/tools/query-data-source.ts b/apps/ai/src/mcp/tools/query-data-source.ts index e437f41dac..cbc91939b6 100644 --- a/apps/ai/src/mcp/tools/query-data-source.ts +++ b/apps/ai/src/mcp/tools/query-data-source.ts @@ -61,6 +61,7 @@ export function registerQueryDataSourceTool(server: McpToolRegistrar) { }), aliases: { source: "data_source", query: "sql" }, hints: { readOnly: true, openWorld: true }, + scopeFamily: "data_sources", phrases: ["Querying a data source", "Running a data source query"], handler: Effect.fn("McpTool.queryDataSource")(function* (params) { const sql = params.sql === undefined ? undefined : Str.trim(params.sql) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 0bd7b926f4..625e9715e6 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -4,7 +4,16 @@ import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import { scopeAllows } from "@maple/domain/http/v2" import type { McpToolSurface } from "@maple/domain/mcp-manifest" import { McpToolOutputs } from "@maple/domain/mcp-outputs" -import { Clock, Effect, Schema, type SchemaAST, Array as Arr, Record as Rec, String as Str } from "effect" +import { + Clock, + Effect, + Option, + Schema, + type SchemaAST, + Array as Arr, + Record as Rec, + String as Str, +} from "effect" import { registerAddDashboardWidgetTool } from "./add-dashboard-widget" import { registerDescribeWarehouseTablesTool } from "./describe-warehouse-tables" import { registerComparePeriodsTool } from "./compare-periods" @@ -175,7 +184,7 @@ export interface MapleToolCatalogEntry { readonly aliases: Readonly> readonly valueAliases: Readonly>>> readonly audience: McpToolAudience - readonly scopeFamily?: string + readonly scopeFamily?: string | ReadonlyArray readonly phrases: McpToolPhrases } @@ -767,9 +776,13 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu if (definition.scopeFamily !== undefined) { const access = definition.hints.readOnly ? "read" : "write" const tenant = yield* CurrentMcpTenant - if (!scopeAllows(tenant.scopes, { family: definition.scopeFamily, access })) { + const missing = Arr.findFirst( + Arr.ensure(definition.scopeFamily), + (family) => !scopeAllows(tenant.scopes, { family, access }), + ) + if (Option.isSome(missing)) { return yield* new McpAuthInvalidError({ - message: `\`${definition.name}\` needs the \`${definition.scopeFamily}:${access}\` scope, which this API key lacks. Add it to the key, or use a full-access key.`, + message: `\`${definition.name}\` needs the \`${missing.value}:${access}\` scope, which this API key lacks. Add it to the key, or use a full-access key.`, reason: "insufficient_scope", }) } diff --git a/apps/ai/src/mcp/tools/search-data-sources.ts b/apps/ai/src/mcp/tools/search-data-sources.ts index e9970ef129..94b11a4cf8 100644 --- a/apps/ai/src/mcp/tools/search-data-sources.ts +++ b/apps/ai/src/mcp/tools/search-data-sources.ts @@ -35,6 +35,7 @@ export function registerSearchDataSourcesTool(server: McpToolRegistrar) { }), aliases: { q: "query", search: "query" }, hints: { readOnly: true }, + scopeFamily: "data_sources", phrases: ["Searching data sources", "Finding where the data lives"], handler: Effect.fn("McpTool.searchDataSources")(function* ({ query, limit }) { const trimmed = Str.trim(query) diff --git a/apps/ai/src/mcp/tools/slos.ts b/apps/ai/src/mcp/tools/slos.ts index 82052c0dfb..92017835ee 100644 --- a/apps/ai/src/mcp/tools/slos.ts +++ b/apps/ai/src/mcp/tools/slos.ts @@ -153,6 +153,7 @@ export function registerSloTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "slos", phrases: ["Checking SLOs"], handler: Effect.fn("McpTool.listSlos")(function* (params) { const tenant = yield* CurrentMcpTenant @@ -273,6 +274,7 @@ export function registerSloTools(server: McpToolRegistrar) { slo: P.text("The SLO's name or id (list_slos lists them)"), }), hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "slos", phrases: ["Inspecting an SLO"], handler: Effect.fn("McpTool.inspectSlo")(function* (params) { const tenant = yield* CurrentMcpTenant @@ -362,6 +364,7 @@ export function registerSloTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: false, destructive: false, idempotent: false }, + scopeFamily: "slos", phrases: ["Adding an SLO"], handler: Effect.fn("McpTool.createSlo")(function* (params) { const tenant = yield* canManage("create_slo") @@ -406,6 +409,7 @@ export function registerSloTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: false, destructive: false, idempotent: true }, + scopeFamily: "slos", phrases: ["Updating an SLO"], handler: Effect.fn("McpTool.updateSlo")(function* (params) { const tenant = yield* canManage("update_slo") @@ -452,6 +456,7 @@ export function registerSloTools(server: McpToolRegistrar) { confirm: P.flag("Must be true. There is no undo."), }), hints: { readOnly: false, destructive: true, idempotent: false }, + scopeFamily: "slos", phrases: ["Deleting an SLO"], handler: Effect.fn("McpTool.deleteSlo")(function* (params) { // A member is refused before being asked to confirm, as create and update are. diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index e08c9ac644..90c8b559c2 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -479,6 +479,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ), }), hints: { readOnly: true, destructive: false, idempotent: true }, + // One page's detail lists its incidents, drafts included. + scopeFamily: ["status_pages", "status_page_incidents"], phrases: ["Reading status pages"], handler: Effect.fn("McpTool.getStatusPage")(function* (params) { const tool = "get_status_page" @@ -541,6 +543,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { publish: publishParameter, }), hints: MUTATION_HINTS, + scopeFamily: "status_page_incidents", phrases: ["Posting a status page update"], handler: Effect.fn("McpTool.postStatusPageUpdate")(function* (params) { const tool = "post_status_page_update" @@ -678,6 +681,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { }), rejectUnknownKeys: true, hints: { readOnly: false, destructive: true, idempotent: false, openWorld: true }, + scopeFamily: "status_pages", phrases: ["Applying a status page config"], handler: Effect.fn("McpTool.applyStatusPageConfig")(function* (params) { const tool = "apply_status_page_config" diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index a466a3a9d9..60b76fe746 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -218,10 +218,10 @@ export interface McpToolSpec< /** Defaults to `public`. */ readonly audience?: McpToolAudience /** - * A v2 scope family a scoped API key must also hold (read for read-only tools, else write), as + * The v2 scope families a scoped API key must also hold (read for read-only tools, else write), as * the matching `/v2/` routes require. For data outside the toolset's baseline families. */ - readonly scopeFamily?: string + readonly scopeFamily?: string | ReadonlyArray readonly phrases: McpToolPhrases /** Retired parameter names mapped to their current name. Accepted, never published. */ readonly aliases?: Readonly> diff --git a/apps/ai/src/mcp/tools/uptime-monitors.ts b/apps/ai/src/mcp/tools/uptime-monitors.ts index 7110a66dd6..da9dcebd8a 100644 --- a/apps/ai/src/mcp/tools/uptime-monitors.ts +++ b/apps/ai/src/mcp/tools/uptime-monitors.ts @@ -257,6 +257,7 @@ export function registerUptimeMonitorTools(server: McpToolRegistrar) { }), aliases: P.SERVICE_ALIASES, hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "uptime_monitors", phrases: ["Checking uptime monitors"], handler: Effect.fn("McpTool.listUptimeMonitors")(function* (params) { const tenant = yield* CurrentMcpTenant @@ -358,6 +359,7 @@ export function registerUptimeMonitorTools(server: McpToolRegistrar) { }), aliases: ALIASES, hints: { readOnly: false, destructive: false, idempotent: false, openWorld: true }, + scopeFamily: "uptime_monitors", phrases: ["Adding an uptime monitor"], handler: Effect.fn("McpTool.createUptimeMonitor")(function* (params) { const tenant = yield* adminOnly("create_uptime_monitor") @@ -416,6 +418,7 @@ export function registerUptimeMonitorTools(server: McpToolRegistrar) { }), aliases: ALIASES, hints: { readOnly: false, destructive: false, idempotent: true, openWorld: true }, + scopeFamily: "uptime_monitors", phrases: ["Updating an uptime monitor"], handler: Effect.fn("McpTool.updateUptimeMonitor")(function* (params) { const tenant = yield* adminOnly("update_uptime_monitor") @@ -472,6 +475,7 @@ export function registerUptimeMonitorTools(server: McpToolRegistrar) { confirm: P.flag("Must be true. There is no undo."), }), hints: { readOnly: false, destructive: true, idempotent: false }, + scopeFamily: "uptime_monitors", phrases: ["Deleting an uptime monitor"], handler: Effect.fn("McpTool.deleteUptimeMonitor")(function* (params) { // A member is refused before being asked to confirm, as create and update are. diff --git a/apps/web/src/page/settings/keys/create-dialog.ts b/apps/web/src/page/settings/keys/create-dialog.ts index 468591c058..a1eb69264b 100644 --- a/apps/web/src/page/settings/keys/create-dialog.ts +++ b/apps/web/src/page/settings/keys/create-dialog.ts @@ -30,6 +30,12 @@ export const SCOPE_FAMILIES = [ { id: "ingest_keys", label: "Ingest keys" }, { id: "attribute_mappings", label: "Attribute mappings" }, { id: "scrape_targets", label: "Scrape targets" }, + { id: "slos", label: "SLOs" }, + { id: "uptime_monitors", label: "Uptime monitors" }, + { id: "ingest_rules", label: "Ingest rules" }, + { id: "data_sources", label: "Data sources" }, + { id: "status_pages", label: "Status pages" }, + { id: "status_page_incidents", label: "Status page incidents" }, { id: "instrumentation", label: "Recommendations" }, { id: "investigations", label: "Investigations" }, { id: "error_issues", label: "Errors" }, diff --git a/apps/web/src/page/settings/keys/view-reference.ts b/apps/web/src/page/settings/keys/view-reference.ts index e25a91aad8..f29b2acb66 100644 --- a/apps/web/src/page/settings/keys/view-reference.ts +++ b/apps/web/src/page/settings/keys/view-reference.ts @@ -35,6 +35,24 @@ const SCOPE_FAMILY_ROWS = [ label: "Scrape targets", description: "Prometheus/PlanetScale scrape targets, probes, and checks", }, + { id: "slos", label: "SLOs", description: "Service level objectives and their error budgets" }, + { + id: "uptime_monitors", + label: "Uptime monitors", + description: "Uptime monitors, their checks and test runs", + }, + { id: "ingest_rules", label: "Ingest rules", description: "Ingest-time drop, sample and redact rules" }, + { + id: "data_sources", + label: "Data sources", + description: "Connected external databases and queries against them", + }, + { id: "status_pages", label: "Status pages", description: "Status pages and their configuration" }, + { + id: "status_page_incidents", + label: "Status page incidents", + description: "Incidents and maintenance posted to a status page", + }, { id: "instrumentation", label: "Recommendations", description: "Instrumentation recommendations" }, { id: "investigations",