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
94 changes: 94 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/scope-families.test.ts
Original file line number Diff line number Diff line change
@@ -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/<family>` 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<string>) =>
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 = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
Effect.provide(effect, Context.makeUnsafe<R>(new Map()))

const call = (name: string, scopes: ReadonlyArray<string>) =>
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`")
})
})
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tools/describe-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions apps/ai/src/mcp/tools/ingest-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/ai/src/mcp/tools/integration-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tools/list-data-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tools/query-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions apps/ai/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -175,7 +184,7 @@ export interface MapleToolCatalogEntry {
readonly aliases: Readonly<Record<string, string>>
readonly valueAliases: Readonly<Record<string, Readonly<Record<string, string>>>>
readonly audience: McpToolAudience
readonly scopeFamily?: string
readonly scopeFamily?: string | ReadonlyArray<string>
readonly phrases: McpToolPhrases
}

Expand Down Expand Up @@ -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",
})
}
Expand Down
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tools/search-data-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions apps/ai/src/mcp/tools/slos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function registerSloTools(server: McpToolRegistrar) {
}),
aliases: P.SERVICE_ALIASES,
hints: { readOnly: true, destructive: false, idempotent: true },
scopeFamily: "slos",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
phrases: ["Checking SLOs"],
handler: Effect.fn("McpTool.listSlos")(function* (params) {
const tenant = yield* CurrentMcpTenant
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/tools/status-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions apps/ai/src/mcp/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<family>` routes require. For data outside the toolset's baseline families.
*/
readonly scopeFamily?: string
readonly scopeFamily?: string | ReadonlyArray<string>
readonly phrases: McpToolPhrases
/** Retired parameter names mapped to their current name. Accepted, never published. */
readonly aliases?: Readonly<Record<string, string>>
Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/tools/uptime-monitors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export function registerUptimeMonitorTools(server: McpToolRegistrar) {
}),
aliases: P.SERVICE_ALIASES,
hints: { readOnly: true, destructive: false, idempotent: true },
scopeFamily: "uptime_monitors",
Comment thread
Makisuo marked this conversation as resolved.
Resolved
phrases: ["Checking uptime monitors"],
handler: Effect.fn("McpTool.listUptimeMonitors")(function* (params) {
const tenant = yield* CurrentMcpTenant
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/page/settings/keys/create-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/page/settings/keys/view-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading