From 73bed4ecb3fb816015f334bdd67bbe7efee8ede7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 15:58:55 +0100 Subject: [PATCH 1/3] fix(mcp): tools outside the baseline families ask for their own scope A scoped API key on the MCP server must read the families an investigation reads. Tools for SLOs, uptime monitors, ingest rules, data sources, status pages and scrape targets sit outside those families, and now require that family's scope from a scoped key, as the matching /v2 routes do. Unscoped keys, OAuth tokens and sessions are unchanged. --- .../tools/__tests__/scope-families.test.ts | 76 +++++++++++++++++++ apps/ai/src/mcp/tools/describe-data-source.ts | 1 + apps/ai/src/mcp/tools/ingest-rules.ts | 5 ++ apps/ai/src/mcp/tools/integration-writes.ts | 3 + apps/ai/src/mcp/tools/list-data-sources.ts | 1 + apps/ai/src/mcp/tools/query-data-source.ts | 1 + apps/ai/src/mcp/tools/search-data-sources.ts | 1 + apps/ai/src/mcp/tools/slos.ts | 5 ++ apps/ai/src/mcp/tools/status-pages.ts | 3 + apps/ai/src/mcp/tools/uptime-monitors.ts | 4 + 10 files changed, 100 insertions(+) create mode 100644 apps/ai/src/mcp/tools/__tests__/scope-families.test.ts 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..9d84e9eba9 --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts @@ -0,0 +1,76 @@ +/** + * 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"], + ["create_slo", "slos:write"], + ["list_uptime_monitors", "uptime_monitors:read"], + ["delete_uptime_monitor", "uptime_monitors:write"], + ["list_ingest_rules", "ingest_rules:read"], + ["create_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"], +] 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("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/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..fe84e10c27 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -479,6 +479,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ), }), hints: { readOnly: true, destructive: false, idempotent: true }, + scopeFamily: "status_pages", phrases: ["Reading status pages"], handler: Effect.fn("McpTool.getStatusPage")(function* (params) { const tool = "get_status_page" @@ -541,6 +542,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 +680,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/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. From 9e082e8823f37e8b91bc0b9428da56f047e61987 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 16:04:52 +0100 Subject: [PATCH 2/3] fix(mcp): make the new scope families grantable, and gate a page's incidents The key dialog and its scope reference now list SLOs, uptime monitors, ingest rules, data sources, status pages and status page incidents, so a restricted key can be given the scope a tool asks for. A tool may name several families, and get_status_page asks for status_page_incidents:read as well, because a page's detail lists its incidents. --- .../tools/__tests__/scope-families.test.ts | 8 +++++++ apps/ai/src/mcp/tools/registry.ts | 21 +++++++++++++++---- apps/ai/src/mcp/tools/status-pages.ts | 3 ++- apps/ai/src/mcp/tools/types.ts | 4 ++-- .../src/page/settings/keys/create-dialog.ts | 6 ++++++ .../src/page/settings/keys/view-reference.ts | 18 ++++++++++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts index 9d84e9eba9..dfdfd9511d 100644 --- a/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts @@ -68,6 +68,14 @@ describe("MCP tools outside the baseline scope families", () => { }) }) + 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") 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/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index fe84e10c27..90c8b559c2 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -479,7 +479,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ), }), hints: { readOnly: true, destructive: false, idempotent: true }, - scopeFamily: "status_pages", + // 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" 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/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", From a98700f6dd927b6a6afecdd172fadea40ea6427e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 16:43:01 +0100 Subject: [PATCH 3/3] test(mcp): pin the scope every one of the 24 tools asks for --- apps/ai/src/mcp/tools/__tests__/scope-families.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts index dfdfd9511d..e91dde4d86 100644 --- a/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/scope-families.test.ts @@ -36,11 +36,19 @@ const BASELINE = Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:wri 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"], @@ -49,6 +57,8 @@ const REQUIRED = [ ["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", () => {