From e64c7f2bb0f2bdd987dc28328f0400f85112b6c1 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:31:30 +0200 Subject: [PATCH 1/6] feat(mcp): query Google Cloud metrics live with PromQL find_gcp_metrics searches a project's Cloud Monitoring metric types or describes one with a starter query; query_gcp_promql runs PromQL over a window or at an instant through GcpPromQlService. The chat renders range results as a chart. The ai Worker binds MAPLE_GCP_SERVICE_ACCOUNT_KEY. --- apps/ai/src/mcp/dispatcher.ts | 2 + apps/ai/src/mcp/resources/instructions.ts | 1 + apps/ai/src/mcp/tools/gcp-promql.test.ts | 319 +++++++++++ apps/ai/src/mcp/tools/gcp-promql.ts | 494 ++++++++++++++++++ apps/ai/src/mcp/tools/registry.ts | 2 + apps/ai/src/mcp/tools/runtime-requirements.ts | 2 + apps/ai/src/worker.ts | 2 + .../landing/src/content/docs/reference/mcp.md | 2 + .../components/promql-chart-rows.test.ts | 30 ++ .../renderers/components/promql-chart-rows.ts | 26 + .../ai-elements/renderers/tool-renderer.tsx | 26 + packages/domain/src/mcp-outputs/catalog.ts | 3 + packages/domain/src/mcp-outputs/gcp.ts | 50 ++ packages/domain/src/mcp-outputs/index.ts | 1 + 14 files changed, 960 insertions(+) create mode 100644 apps/ai/src/mcp/tools/gcp-promql.test.ts create mode 100644 apps/ai/src/mcp/tools/gcp-promql.ts create mode 100644 apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts create mode 100644 apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts create mode 100644 packages/domain/src/mcp-outputs/gcp.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index 3df645f35d..63faecc68c 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -2,6 +2,7 @@ import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" +import { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" import { SandboxClient } from "@maple/backend/sandbox/client" import { CloudflareRepoSandboxLive } from "@maple/backend/services/sandbox/CloudflareRepoSandbox" import type { SandboxError } from "@yielded/agent/sandbox" @@ -295,6 +296,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( ErrorIssueWorkflowService.layer, ErrorPolicyService.layer, ErrorsService.layer, + GcpPromQlService.layer, IssueFixVerificationService.layer, QueryEngineService.layer, RecommendationIssueService.layer, diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index e0227be255..ad8933664d 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -109,6 +109,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - Performance analysis: find_slow_traces -> inspect_trace -> get_service_top_operations - Trend analysis: query_data (timeseries or breakdown) - Service discovery: list_services -> diagnose_service +- Google Cloud metrics, live (Cloud Monitoring through Google's PromQL API, not the ingested \`gcp.*\` warehouse metrics): find_gcp_metrics search=... -> find_gcp_metrics metric=... (kind, labels, starter query) -> query_gcp_promql. Aggregate with \`sum by\`/\`avg by\`: Google bills per series returned - Infrastructure (hosts, Kubernetes pods/nodes/workloads, Docker containers): list_infra (no \`kind\` = overview of what reports, hottest first) -> inspect_infra (CPU/memory over the window with the peak time, a node's or workload's pods). diagnose_service shows the workload and pods a service runs on. Resource metrics are also chartable: create_dashboard group_by \`resource.host.name\` / \`resource.k8s.pod.name\` on source=metrics - 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 diff --git a/apps/ai/src/mcp/tools/gcp-promql.test.ts b/apps/ai/src/mcp/tools/gcp-promql.test.ts new file mode 100644 index 0000000000..508558c24e --- /dev/null +++ b/apps/ai/src/mcp/tools/gcp-promql.test.ts @@ -0,0 +1,319 @@ +import { assert, describe, it } from "@effect/vitest" +import { + GcpMetricDescriptor, + GcpMetricSearchResponse, + GcpPromQlFailedError, + GcpPromQlInvalidQueryError, + GcpPromQlResult, + GcpPromQlUnavailableError, + type GcpPromQlInstantRequest, + type GcpPromQlRangeRequest, + OrgId, + UserId, +} from "@maple/domain/http" +import { GcpProjectId } from "@maple/domain/primitives" +import { + GcpPromQlService, + type GcpPromQlServiceApi, +} from "@maple/backend/services/integrations/GcpPromQlService" +import { Effect, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import { renderToolDoc } from "../lib/tool-doc" +import { registerGcpPromQlTools, starterQuery } from "./gcp-promql" +import type { McpToolError, McpToolRegistrar } from "./types" + +interface Answer { + readonly text: string + readonly structured: Readonly> +} + +type Run = ( + params: Readonly>, +) => Effect.Effect + +/** Decode, handle, encode and render, as the registry does. */ +const tools = new Map() +registerGcpPromQlTools({ + define: (spec) => { + const run = (params: Readonly>) => + Schema.decodeUnknownEffect(spec.parameters)(params).pipe( + Effect.flatMap(spec.handler), + Effect.map((output) => ({ + text: renderToolDoc(spec.render(output)), + structured: Schema.encodeUnknownSync(spec.output)(output) as Answer["structured"], + })), + ) + tools.set(spec.name, run as Run) + }, +} satisfies McpToolRegistrar) + +const orgId = Schema.decodeUnknownSync(OrgId)("org_gcp") +const project = Schema.decodeUnknownSync(GcpProjectId)("shop-prod") +const NOW = Date.parse("2026-10-15T12:00:00Z") +const WINDOW = { start_time: "2026-10-15 10:00:00", end_time: "2026-10-15 12:00:00" } + +const call = ( + name: string, + params: Readonly>, + service: Partial, +) => + TestClock.setTime(NOW).pipe( + Effect.andThen(tools.get(name)!(params)), + Effect.provide( + Layer.mergeAll( + Layer.succeed(CurrentMcpTenant, { + orgId, + userId: Schema.decodeUnknownSync(UserId)("user_gcp"), + roles: [], + authMode: "self_hosted", + } as CurrentMcpTenant["Service"]), + Layer.succeed(GcpPromQlService, service as GcpPromQlServiceApi), + ), + ), + ) + +const result = (series: ReadonlyArray<{ labels: Record; points: Array<[number, number]> }>) => + Schema.decodeUnknownSync(GcpPromQlResult)({ + resultType: "matrix", + series, + projects: ["shop-prod"], + failures: [], + }) + +describe("starterQuery", () => { + const metric = { + type: "run.googleapis.com/request_count", + monitoredResourceTypes: ["cloud_run_revision"], + } + + it("rates counters and averages gauges, aggregated so one series comes back per project", () => { + assert.strictEqual( + starterQuery({ ...metric, metricKind: "DELTA", valueType: "INT64" }), + 'sum(rate({"run.googleapis.com/request_count"}[5m]))', + ) + assert.strictEqual( + starterQuery({ ...metric, metricKind: "CUMULATIVE", valueType: "DOUBLE" }), + 'sum(rate({"run.googleapis.com/request_count"}[5m]))', + ) + assert.strictEqual( + starterQuery({ ...metric, metricKind: "GAUGE", valueType: "DOUBLE" }), + 'avg({"run.googleapis.com/request_count"})', + ) + }) + + it("takes a distribution's p95 and names a resource type when several are possible", () => { + assert.strictEqual( + starterQuery({ + type: "run.googleapis.com/request_latencies", + metricKind: "DELTA", + valueType: "DISTRIBUTION", + monitoredResourceTypes: ["cloud_run_instance", "cloud_run_revision"], + }), + 'histogram_quantile(0.95, sum by (le) (rate({"run.googleapis.com/request_latencies_bucket", monitored_resource="cloud_run_instance"}[5m])))', + ) + }) + + it("offers none for a STRING metric, which PromQL cannot read", () => { + assert.isUndefined(starterQuery({ ...metric, metricKind: "GAUGE", valueType: "STRING" })) + }) +}) + +describe("query_gcp_promql", () => { + it.effect("queries the window at a step from its width and keeps the highest series", () => + Effect.gen(function* () { + const seen: Array = [] + const series = Array.from({ length: 150 }, (_, index) => ({ + labels: { project_id: "shop-prod", service: `svc-${index}` }, + points: [[1_760_520_000, 1] as [number, number], [1_760_520_060, index] as [number, number]], + })) + const answer = yield* call( + "query_gcp_promql", + { query: ' sum by (service) (rate({"run.googleapis.com/request_count"}[5m])) ', ...WINDOW }, + { + queryRange: (_org, request) => { + seen.push(request) + return Effect.succeed(result(series)) + }, + }, + ) + assert.deepStrictEqual( + { ...seen[0] }, + { + query: 'sum by (service) (rate({"run.googleapis.com/request_count"}[5m]))', + startTime: "2026-10-15 10:00:00", + endTime: "2026-10-15 12:00:00", + stepSeconds: 60, + }, + ) + const kept = answer.structured.series as ReadonlyArray<{ labels: Record }> + assert.strictEqual(kept.length, 100) + assert.strictEqual(kept[0]?.labels.service, "svc-149") + assert.strictEqual(answer.structured.totalSeries, 150) + assert.include( + answer.text, + '| {project_id="shop-prod", service="svc-149"} | 149 | 1 | 75 | 149 | 2 |', + ) + assert.notInclude(answer.text, "svc-129") + assert.include(answer.text, "Showing 20 of 150 series") + assert.include(answer.text, "Google bills per series returned") + assert.include(answer.text, '`find_gcp_metrics metric="run.googleapis.com/request_count"`') + }), + ) + + it.effect("evaluates an instant query at end_time and lists what could not be read", () => + Effect.gen(function* () { + const seen: Array = [] + const answer = yield* call( + "query_gcp_promql", + { query: "up", kind: "instant", projects: "shop-prod", ...WINDOW }, + { + queryInstant: (_org, request) => { + seen.push(request) + return Effect.succeed( + Schema.decodeUnknownSync(GcpPromQlResult)({ + resultType: "vector", + series: [ + { labels: { project_id: "shop-prod" }, points: [[1_760_529_600, 0.5]] }, + ], + projects: ["shop-prod"], + failures: [ + { scope: "projects/shop-dev", message: "Run the setup script again." }, + ], + }), + ) + }, + }, + ) + assert.deepStrictEqual( + { ...seen[0] }, + { query: "up", time: "2026-10-15 12:00:00", projects: [project] }, + ) + assert.include(answer.text, '| {project_id="shop-prod"} | 0.5 |') + assert.include(answer.text, "- projects/shop-dev: Run the setup script again.") + assert.notProperty(answer.structured, "stepSeconds") + }), + ) + + it.effect("hands Google's refusal of the query back as invalid input", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + call( + "query_gcp_promql", + { query: "rate(x)", ...WINDOW }, + { + queryRange: () => + Effect.fail( + new GcpPromQlInvalidQueryError({ + message: "expected range vector", + projectId: project, + }), + ), + }, + ), + ) + assert.strictEqual(error._tag, "@maple/mcp/errors/McpInvalidInputError") + assert.include(error.message, "expected range vector") + }), + ) + + it.effect("maps a deployment without a Google identity and an unreadable setup to their kinds", () => + Effect.gen(function* () { + const unavailable = yield* Effect.flip( + call( + "query_gcp_promql", + { query: "up", ...WINDOW }, + { queryRange: () => Effect.fail(new GcpPromQlUnavailableError({ message: "no key" })) }, + ), + ) + assert.strictEqual(unavailable._tag, "@maple/mcp/errors/McpUnavailableError") + const failed = yield* Effect.flip( + call( + "query_gcp_promql", + { query: "up", ...WINDOW }, + { + queryRange: () => + Effect.fail( + new GcpPromQlFailedError({ + message: "Run the setup script again.", + failures: [], + }), + ), + }, + ), + ) + assert.strictEqual(failed._tag, "@maple/mcp/errors/McpNotReadyError") + assert.include(failed.message, "setup script") + }), + ) +}) + +describe("find_gcp_metrics", () => { + it.effect("searches, says when more matched, and describes a metric with a starter query", () => + Effect.gen(function* () { + const limits: Array = [] + const answer = yield* call( + "find_gcp_metrics", + { + search: "run.googleapis.com/request", + metric: "run.googleapis.com/request_latencies", + limit: 3, + }, + { + searchMetrics: (_org, request) => { + limits.push(request.limit) + return Effect.succeed( + new GcpMetricSearchResponse({ + projectId: project, + metrics: [ + "run.googleapis.com/request_count", + "run.googleapis.com/request_latencies_bucket", + "run.googleapis.com/request_latencies_count", + "run.googleapis.com/request_latencies_sum", + ], + }), + ) + }, + describeMetric: () => + Effect.succeed( + new GcpMetricDescriptor({ + type: "run.googleapis.com/request_latencies", + metricKind: "DELTA", + valueType: "DISTRIBUTION", + unit: "ms", + description: "Request latency", + monitoredResourceTypes: ["cloud_run_revision"], + labels: [{ key: "response_code", description: "HTTP status" }], + }), + ), + }, + ) + assert.deepStrictEqual(limits, [4]) + const search = answer.structured.search as { metrics: ReadonlyArray; truncated: boolean } + assert.strictEqual(search.metrics.length, 3) + assert.isTrue(search.truncated) + assert.include(answer.text, "| response_code | HTTP status |") + assert.include( + answer.text, + '`query_gcp_promql query="histogram_quantile(0.95, sum by (le) (rate({\\"run.googleapis.com/request_latencies_bucket\\"}[5m])))"`', + ) + // A distribution's names describe as one metric; a plain `_count` counter stays itself. + assert.include( + answer.text, + '`find_gcp_metrics metric="run.googleapis.com/request_count" project="shop-prod"`', + ) + assert.include( + answer.text, + '`find_gcp_metrics metric="run.googleapis.com/request_latencies" project="shop-prod"`', + ) + assert.notInclude(answer.text, 'metric="run.googleapis.com/request_latencies_count"') + }), + ) + + it.effect("asks for a search or a metric", () => + Effect.gen(function* () { + const error = yield* Effect.flip(call("find_gcp_metrics", {}, {})) + assert.strictEqual(error._tag, "@maple/mcp/errors/McpInvalidInputError") + }), + ) +}) diff --git a/apps/ai/src/mcp/tools/gcp-promql.ts b/apps/ai/src/mcp/tools/gcp-promql.ts new file mode 100644 index 0000000000..188659d2d3 --- /dev/null +++ b/apps/ai/src/mcp/tools/gcp-promql.ts @@ -0,0 +1,494 @@ +/** + * Live reads of the org's Google Cloud Monitoring metrics through Google's PromQL API. Nothing here + * touches the warehouse: the ingested `gcp.*` metrics stay with list_metrics and query_data. + */ +import { Effect, Schema } from "effect" +import { + GcpMetricType, + GcpPromQlInstantRequest, + GcpPromQlRangeRequest, + type IntegrationsNotFoundError, +} from "@maple/domain/http" +import { FindGcpMetricsOutput, QueryGcpPromQlOutput } from "@maple/domain/mcp-outputs" +import { GcpProjectId } from "@maple/domain/primitives" +import { type GcpPromQlError, GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" +import { computeBucketSecondsForRange } from "@maple/query-engine" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import * as P from "../lib/params" +import { truncate } from "../lib/format" +import { doc, type NextCall } from "../lib/tool-doc" +import { + type McpToolError, + McpInvalidInputError, + McpNotReadyError, + McpQueryError, + McpUnavailableError, + type McpToolRegistrar, +} from "./types" + +/** Google keeps most system metrics six weeks. */ +const WINDOW = P.timeWindow({ defaultHours: 1, maxHours: 42 * 24 }) +/** Series kept in the output (the chat chart's payload), highest last value first. */ +const MAX_SERIES = 100 +/** Series the model reads. */ +const SHOWN_SERIES = 20 +const DISTRIBUTION_SUFFIX = /_(bucket|count|sum)$/u +const HINTS = { readOnly: true, openWorld: true } as const + +const toMcpError = + (tool: string, projectParameter: string) => + (error: GcpPromQlError | IntegrationsNotFoundError): McpToolError => { + switch (error._tag) { + case "@maple/http/errors/GcpPromQlInvalidQueryError": + return new McpInvalidInputError({ + message: `Google refused the query (project ${error.projectId}): ${error.message}`, + parameter: "query", + }) + case "@maple/http/errors/GcpPromQlTooManyProjectsError": + return new McpInvalidInputError({ + message: `${error.message} Projects: ${error.projects.slice(0, 20).join(", ")}${error.projects.length > 20 ? ` and ${error.projects.length - 20} more` : ""}.`, + parameter: projectParameter, + }) + case "@maple/http/errors/IntegrationsValidationError": + return new McpInvalidInputError({ message: error.message, parameter: projectParameter }) + case "@maple/http/errors/IntegrationsNotFoundError": + return new McpInvalidInputError({ + message: `${error.message} Find the name with find_gcp_metrics search=...`, + parameter: "metric", + }) + case "@maple/http/errors/GcpPromQlUnavailableError": + return new McpUnavailableError({ message: error.message, capability: "gcp_live_query" }) + case "@maple/http/errors/IntegrationsNotConnectedError": + return new McpUnavailableError({ message: error.message, capability: "gcp_connection" }) + // Every project failed; the message says why per project and what fixes it (mostly a + // setup-script run whose grant takes minutes to apply). + case "@maple/http/errors/GcpPromQlFailedError": + return new McpNotReadyError({ message: error.message, retryAfterSeconds: 300 }) + case "@maple/http/errors/IntegrationsUpstreamError": + case "@maple/http/errors/IntegrationsPersistenceError": + return new McpQueryError({ message: error.message, pipeName: tool, cause: error }) + } + } + +const decodeProject = (project: string | undefined) => + project === undefined + ? Effect.succeed(undefined) + : Schema.decodeUnknownEffect(GcpProjectId)(project).pipe( + Effect.mapError( + () => + new McpInvalidInputError({ + message: `\`${project}\` is not a Google Cloud project id.`, + parameter: "project", + example: 'project="my-project-123"', + }), + ), + ) + +/** A query for a metric type's kind: rate for counters, avg for gauges, p95 for distributions. */ +export const starterQuery = (metric: { + readonly type: string + readonly metricKind: string + readonly valueType: string + readonly monitoredResourceTypes: ReadonlyArray +}): string | undefined => { + if (metric.valueType === "STRING") return undefined + // Google refuses a selector that could match several resource types. + const resource = + metric.monitoredResourceTypes.length > 1 + ? `, monitored_resource="${metric.monitoredResourceTypes[0]}"` + : "" + if (metric.valueType === "DISTRIBUTION") { + return `histogram_quantile(0.95, sum by (le) (rate({"${metric.type}_bucket"${resource}}[5m])))` + } + const selector = `{"${metric.type}"${resource}}` + return metric.metricKind === "GAUGE" ? `avg(${selector})` : `sum(rate(${selector}[5m]))` +} + +const seriesName = (labels: Readonly>): string => { + const entries = Object.entries(labels).filter(([key]) => key !== "__name__") + return `{${entries.map(([key, value]) => `${key}="${value}"`).join(", ")}}` +} + +const formatValue = (value: number): string => value.toLocaleString("en-US", { maximumSignificantDigits: 4 }) + +const lastValue = (points: ReadonlyArray): number => + points.at(-1)?.[1] ?? Number.NEGATIVE_INFINITY + +export function registerGcpPromQlTools(server: McpToolRegistrar) { + server.define({ + name: "find_gcp_metrics", + title: "Find Google Cloud Metrics", + description: + "Find Google Cloud Monitoring metric types in the connected Google Cloud projects by name substring (`search`), or describe one (`metric`): kind, value type, unit, monitored resource types, labels, and a starter PromQL query for query_gcp_promql. Reads Google live; the ingested `gcp.*` warehouse metrics are in list_metrics and query_data.", + parameters: Schema.Struct({ + search: P.optionalText( + "Case-insensitive substring of the metric type, e.g. `run.googleapis.com/` or `cpu/utilization`", + ), + metric: P.optionalText( + "A metric type to describe, e.g. `run.googleapis.com/request_latencies` (a distribution's `_bucket` name works too)", + ), + project: P.optionalText("Project whose metrics to read (default: the first connected project)"), + limit: P.limit({ default: 50, max: 200, noun: "metric names" }), + }), + output: FindGcpMetricsOutput, + hints: HINTS, + phrases: ["Looking up Google Cloud metrics", "Reading a Google Cloud metric"], + handler: Effect.fn("McpTool.findGcpMetrics")(function* (params) { + if (params.search === undefined && params.metric === undefined) { + return yield* new McpInvalidInputError({ + message: "Pass `search` to find metric types, or `metric` to describe one.", + parameter: "search", + example: 'search="run.googleapis.com/request"', + }) + } + const metric = params.metric + if (metric !== undefined && !Schema.is(GcpMetricType)(metric)) { + return yield* new McpInvalidInputError({ + message: `\`${metric}\` is not a Cloud Monitoring metric type.`, + parameter: "metric", + example: 'metric="run.googleapis.com/request_count"', + }) + } + const project = yield* decodeProject(params.project) + const tenant = yield* CurrentMcpTenant + const gcp = yield* GcpPromQlService + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) + const fail = toMcpError("find_gcp_metrics", "project") + + const text = params.search + const search = + text === undefined + ? undefined + : yield* gcp + .searchMetrics(tenant.orgId, { search: text, limit: params.limit + 1, project }) + .pipe( + Effect.mapError(fail), + Effect.map((found) => ({ + text, + projectId: found.projectId, + metrics: found.metrics.slice(0, params.limit), + truncated: found.metrics.length > params.limit, + })), + ) + const described = + metric === undefined + ? undefined + : yield* gcp.describeMetric(tenant.orgId, { type: metric, project }).pipe( + Effect.mapError(fail), + Effect.map((descriptor) => { + const query = starterQuery(descriptor) + return { + type: descriptor.type, + metricKind: descriptor.metricKind, + valueType: descriptor.valueType, + unit: descriptor.unit, + description: descriptor.description, + monitoredResourceTypes: descriptor.monitoredResourceTypes, + labels: descriptor.labels, + ...(query === undefined ? undefined : { starterQuery: query }), + } + }), + ) + return { + ...(search === undefined ? undefined : { search }), + ...(described === undefined ? undefined : { metric: described }), + } + }), + render: (output) => { + const { search, metric } = output + // A distribution lists as `_bucket`, `_count` and `_sum`: one type. `request_count` alone is its own. + const names = search?.metrics ?? [] + const distributions = new Set( + names + .filter((name) => name.endsWith("_bucket")) + .map((name) => name.slice(0, -"_bucket".length)), + ) + const types = [ + ...new Set( + names.map((name) => { + const base = name.replace(DISTRIBUTION_SUFFIX, "") + return distributions.has(base) ? base : name + }), + ), + ] + const resources = metric?.monitoredResourceTypes ?? [] + return { + title: "Google Cloud Metrics", + scope: [ + ["Project", search?.projectId], + ["Search", search?.text], + ], + ...(search !== undefined && search.metrics.length === 0 + ? { + empty: { + message: `No metric type in project ${search.projectId} contains "${search.text}".`, + hints: [ + "Search a shorter piece of the name, or the service prefix such as `run.googleapis.com/` or `compute.googleapis.com/`.", + ], + }, + } + : undefined), + blocks: [ + ...(search === undefined || search.metrics.length === 0 + ? [] + : [ + doc.list(search.metrics), + doc.text( + "Names ending `_bucket`, `_count`, `_sum` are one distribution. A listed name has a descriptor, not necessarily data.", + ), + ]), + ...(metric === undefined + ? [] + : [ + doc.heading(metric.type), + doc.fields([ + ["Kind", metric.metricKind], + ["Value type", metric.valueType], + ["Unit", metric.unit === "" ? "-" : metric.unit], + ["Resource types", resources.join(", ")], + ["Description", metric.description], + ]), + ...(metric.labels.length === 0 + ? [] + : [ + doc.table( + ["Label", "Description"], + metric.labels.map((label) => [ + label.key, + truncate(label.description, 100), + ]), + ), + ]), + ...(metric.starterQuery === undefined + ? [doc.text("PromQL cannot read STRING metrics.")] + : [ + doc.code("promql", metric.starterQuery), + ...(resources.length > 1 + ? [ + doc.text( + `The \`monitored_resource\` matcher is required: this metric has ${resources.length} resource types (${resources.join(", ")}).`, + ), + ] + : []), + ]), + ]), + ], + ...(search?.truncated === true + ? { + truncation: { + shown: search.metrics.length, + noun: "metric names; narrow `search` for the rest", + }, + } + : undefined), + next: [ + ...(metric?.starterQuery === undefined + ? [] + : [ + doc.next( + "query_gcp_promql", + { query: metric.starterQuery }, + "run the starter query", + ), + ]), + ...types + .slice(0, 3) + .map((type) => + doc.next( + "find_gcp_metrics", + { metric: type, project: search?.projectId }, + "its kind, labels and a starter query", + ), + ), + ], + } + }, + }) + + server.define({ + name: "query_gcp_promql", + title: "Query Google Cloud Metrics", + description: + 'Run PromQL live against Google Cloud Monitoring in every connected Google Cloud project (or the `projects` named), over the window (kind=timeseries) or once at end_time (kind=instant). Aggregate with `sum by (...)` or `avg by (...)`: Google bills per time series returned. Metric names use the UTF-8 form `{"run.googleapis.com/request_count", monitored_resource="cloud_run_revision"}`; a metric with several resource types needs the `monitored_resource` matcher. Each project is queried on its own, so aggregation stays within a project and every series carries `project_id`. Get names and a starter query from find_gcp_metrics. These are live Google reads, separate from the ingested `gcp.*` warehouse metrics in query_data.', + parameters: Schema.Struct({ + query: P.text( + 'PromQL, e.g. `sum by (service_name) (rate({"run.googleapis.com/request_count", monitored_resource="cloud_run_revision"}[5m]))`', + ), + kind: P.optionalOneOf( + ["timeseries", "instant"], + "timeseries (default): points over the window; instant: one value per series at end_time", + ), + ...WINDOW.fields, + bucket_seconds: P.optionalNumber( + "Point spacing in seconds, 60 to 86400 (timeseries only; default: from the window, about 100 points)", + ), + projects: P.optionalList( + "Only these Google Cloud project ids, at most 20 (default: every connected project)", + ), + }), + output: QueryGcpPromQlOutput, + hints: HINTS, + phrases: ["Querying Google Cloud", "Reading Google Cloud metrics"], + handler: Effect.fn("McpTool.queryGcpPromQl")(function* (params) { + const { st, et } = yield* WINDOW.resolve(params, "query_gcp_promql") + const tenant = yield* CurrentMcpTenant + const gcp = yield* GcpPromQlService + const kind = params.kind ?? "timeseries" + const query = params.query.trim() + const projects = params.projects === undefined ? undefined : { projects: params.projects } + const stepSeconds = Math.min( + 86_400, + Math.max(60, Math.floor(params.bucket_seconds ?? computeBucketSecondsForRange(st, et))), + ) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.ai.kind": kind }) + const invalid = (error: Schema.SchemaError) => + new McpInvalidInputError({ message: error.message }) + const fail = toMcpError("query_gcp_promql", "projects") + + const result = + kind === "instant" + ? yield* Schema.decodeUnknownEffect(GcpPromQlInstantRequest)({ + query, + time: et, + ...projects, + }).pipe( + Effect.mapError(invalid), + Effect.flatMap((request) => + gcp.queryInstant(tenant.orgId, request).pipe(Effect.mapError(fail)), + ), + ) + : yield* Schema.decodeUnknownEffect(GcpPromQlRangeRequest)({ + query, + startTime: st, + endTime: et, + stepSeconds, + ...projects, + }).pipe( + Effect.mapError(invalid), + Effect.flatMap((request) => + gcp.queryRange(tenant.orgId, request).pipe(Effect.mapError(fail)), + ), + ) + yield* Effect.annotateCurrentSpan("result.seriesCount", result.series.length) + return { + query, + kind, + timeRange: { start: st, end: et }, + ...(kind === "timeseries" ? { stepSeconds } : undefined), + series: [...result.series] + .sort((a, b) => lastValue(b.points) - lastValue(a.points)) + .slice(0, MAX_SERIES), + totalSeries: result.series.length, + projects: result.projects, + failures: result.failures, + } + }), + render: (output) => { + const instant = output.kind === "instant" + const shown = output.series.slice(0, SHOWN_SERIES) + // A distribution's `_bucket` name describes too. + const metricType = /\{\s*"([^"]+)"/u.exec(output.query)?.[1] + const rows = shown.map((series) => { + const values = series.points.map(([, value]) => value) + const name = truncate(seriesName(series.labels), 120) + if (instant) return [name, values.length === 0 ? "-" : formatValue(values[0])] + if (values.length === 0) return [name, "-", "-", "-", "-", "0"] + return [ + name, + formatValue(values[values.length - 1]), + formatValue(Math.min(...values)), + formatValue(values.reduce((sum, value) => sum + value, 0) / values.length), + formatValue(Math.max(...values)), + String(values.length), + ] + }) + const next: Array = [ + ...(metricType === undefined + ? [] + : [ + doc.next( + "find_gcp_metrics", + { metric: metricType }, + "its kind, labels and resource types", + ), + ]), + ...(instant + ? [ + doc.next( + "query_gcp_promql", + { + query: output.query, + start_time: output.timeRange.start, + end_time: output.timeRange.end, + }, + "the same query over the window", + ), + ] + : []), + ] + return { + title: "Google Cloud PromQL", + scope: [ + [ + instant ? "At" : "Window", + instant + ? output.timeRange.end + : `${output.timeRange.start} to ${output.timeRange.end}`, + ], + ["Step", output.stepSeconds === undefined ? undefined : `${output.stepSeconds}s`], + ["Projects", output.projects.join(", ")], + ], + notices: [ + "Read live from Google Cloud Monitoring, one call per project: aggregation stays within a project and every series carries `project_id`.", + ...(output.totalSeries > SHOWN_SERIES + ? [ + "Aggregate with `sum by (...)` or `avg by (...)` to cut the series: Google bills per series returned.", + ] + : []), + ], + ...(output.totalSeries === 0 + ? { + empty: { + message: "Google returned no series.", + hints: [ + "The metric may have no data in this window: widen start_time, or check the name with find_gcp_metrics.", + "An instant query looks back 5 minutes; a sparse metric needs kind=timeseries or a range selector.", + ], + }, + } + : undefined), + blocks: [ + doc.code("promql", output.query), + ...(rows.length === 0 + ? [] + : [ + doc.table( + instant + ? ["Series", "Value"] + : ["Series", "Last", "Min", "Avg", "Max", "Points"], + rows, + ), + ]), + ...(output.failures.length === 0 + ? [] + : [ + doc.heading("Not read"), + doc.list( + output.failures.map((failure) => `${failure.scope}: ${failure.message}`), + ), + ]), + ], + ...(output.totalSeries > shown.length + ? { + truncation: { + shown: shown.length, + total: output.totalSeries, + noun: "series, highest last value first", + }, + } + : undefined), + next, + } + }, + }) +} diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index f4d9eb26c2..d4e7f1cc9c 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -52,6 +52,7 @@ import { registerTransitionErrorIssuesTool } from "./transition-error-issues" import { registerUpdateErrorNotificationPolicyTool } from "./update-error-notification-policy" import { registerListDashboardsTool } from "./list-dashboards" import { registerListMetricsTool } from "./list-metrics" +import { registerGcpPromQlTools } from "./gcp-promql" import { registerListServicesTool } from "./list-services" import { registerQueryDataTool } from "./query-data" import { registerRunSqlTool } from "./run-sql" @@ -380,6 +381,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerErrorDetailTool(registrar) registerListMetricsTool(registrar) registerQueryDataTool(registrar) + registerGcpPromQlTools(registrar) registerRunSqlTool(registrar) registerServiceMapTool(registrar) registerListAlertRulesTool(registrar) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index d47204c1ec..7e35f2de6f 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -11,6 +11,7 @@ import type { ErrorPolicyService } from "@maple/backend/services/errors/ErrorPol import type { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import type { IssueFixVerificationService } from "@maple/backend/services/errors/IssueFixVerificationService" import type { RecommendationIssueService } from "@maple/backend/services/errors/RecommendationIssueService" +import type { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" import type { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" import type { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import type { RepoSandboxService } from "@maple/backend/services/sandbox/RepoSandboxService" @@ -35,6 +36,7 @@ export type McpToolRuntimeRequirements = | ErrorIssueWorkflowService | ErrorPolicyService | ErrorsService + | GcpPromQlService | IssueFixVerificationService | QueryEngineService | RecommendationIssueService diff --git a/apps/ai/src/worker.ts b/apps/ai/src/worker.ts index 8872fa9aa1..62f01e839c 100644 --- a/apps/ai/src/worker.ts +++ b/apps/ai/src/worker.ts @@ -61,6 +61,8 @@ const configuredEnv = ({ stage, region, domains, profile }: MapleStackContext) = githubAppSourceEnv, // Required wherever a sandbox Worker deploys: without it every sandbox call is refused. ...(profile.deploys.sandbox ? [requireSecretEntry("SANDBOX_INTERNAL_SERVICE_TOKEN")] : []), + // Sign-in for the live Google Cloud PromQL tools; unset answers them as unavailable. + optionalSecret("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), // Dev-only escape hatch from per-org BYO rows (see apps/api/src/resources/env.ts). optionalPlain("MAPLE_IGNORE_ORG_CLICKHOUSE"), ) diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md index 93fb015722..881de0c081 100644 --- a/apps/landing/src/content/docs/reference/mcp.md +++ b/apps/landing/src/content/docs/reference/mcp.md @@ -59,6 +59,7 @@ The server exposes the tools below. Read-only tools are marked **read**; the res | `compare_periods` | read | Error rate, throughput and P95 across two periods, overall and per service, with regressions flagged | | `explore_attributes` | read | Attribute keys and their values for filtering; `source=services` lists environments and commit SHAs | | `list_metrics` | read | Custom metrics with type, unit, monotonicity and data volume | +| `find_gcp_metrics` | read | Google Cloud Monitoring metric types by name, or one described with a starter PromQL query | | `describe_warehouse_tables` | read | Tables and columns available to raw SQL, with sorting keys and notes on units and casing | ### Traces, logs and queries @@ -72,6 +73,7 @@ The server exposes the tools below. Read-only tools are marked **read**; the res | `search_logs` | read | Log entries, newest first, filtered by service, severity, body text, trace or span | | `mine_log_patterns` | read | Cluster log messages into templates with counts and a severity and service breakdown | | `query_data` | read | Aggregate traces, logs, metrics or product events into a time series or top-N breakdown | +| `query_gcp_promql` | read | Run PromQL live against Google Cloud Monitoring in the connected projects | | `run_sql` | read | Run one read-only ClickHouse `SELECT`; see the [SQL reference](/docs/reference/sql) | ### Errors diff --git a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts new file mode 100644 index 0000000000..b83e38f4ec --- /dev/null +++ b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { promQlChartRows } from "./promql-chart-rows" + +describe("promQlChartRows", () => { + it("merges series into time-ordered buckets keyed by their labels", () => { + const rows = promQlChartRows([ + { + labels: { __name__: "x", project_id: "shop-prod", service: "api" }, + points: [ + [1_760_000_060, 2], + [1_760_000_000, 1], + ], + }, + { labels: { project_id: "shop-dev", service: "api" }, points: [[1_760_000_060, 5]] }, + ]) + expect(rows).toEqual([ + { bucket: "2025-10-09T08:53:20.000Z", series: { "project_id=shop-prod, service=api": 1 } }, + { + bucket: "2025-10-09T08:54:20.000Z", + series: { "project_id=shop-prod, service=api": 2, "project_id=shop-dev, service=api": 5 }, + }, + ]) + }) + + it("names a label-less series", () => { + expect(promQlChartRows([{ labels: {}, points: [[0, 3]] }])).toEqual([ + { bucket: "1970-01-01T00:00:00.000Z", series: { value: 3 } }, + ]) + }) +}) diff --git a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts new file mode 100644 index 0000000000..fb1e59f06b --- /dev/null +++ b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts @@ -0,0 +1,26 @@ +import type { StructuredToolData } from "@maple/domain" + +type PromQlSeries = StructuredToolData<"query_gcp_promql">["series"][number] + +const seriesKey = (labels: PromQlSeries["labels"]): string => { + const entries = Object.entries(labels).filter(([key]) => key !== "__name__") + return entries.length === 0 ? "value" : entries.map(([key, value]) => `${key}=${value}`).join(", ") +} + +/** PromQL series (`[epoch seconds, value]` points) as the `{ bucket, series }` rows `QueryChart` draws. */ +export function promQlChartRows( + series: ReadonlyArray, +): Array<{ bucket: string; series: Record }> { + const rows = new Map>() + for (const one of series) { + const key = seriesKey(one.labels) + for (const [seconds, value] of one.points) { + const row = rows.get(seconds) ?? {} + row[key] = value + rows.set(seconds, row) + } + } + return [...rows.entries()] + .sort(([a], [b]) => a - b) + .map(([seconds, values]) => ({ bucket: new Date(seconds * 1000).toISOString(), series: values })) +} diff --git a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx index 94a3761689..b28f9c9072 100644 --- a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx +++ b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx @@ -5,6 +5,7 @@ import { DataTable } from "./components/data-table" import { ErrorList } from "./components/error-list" import { LogList } from "./components/log-list" import { MetricsList } from "./components/metrics-list" +import { promQlChartRows } from "./components/promql-chart-rows" import { QueryChart } from "./components/query-chart" import { SpanTree } from "./components/span-tree" import { StatCards } from "./components/stat-cards" @@ -169,6 +170,31 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { ) } + case "query_gcp_promql": + if (output.data.kind === "timeseries") { + return ( + + ) + } + return ( + + Object.entries(row.series).map(([name, value]) => [name, String(value)]), + ), + title: output.data.query, + }} + /> + ) + case "service_map": return ( Date: Thu, 15 Oct 2026 01:32:12 +0200 Subject: [PATCH 2/6] feat(mcp): a PromQL answer of too many series asks the agent to aggregate --- apps/ai/src/mcp/tools/gcp-promql.test.ts | 23 +++++++++++++++++++++++ apps/ai/src/mcp/tools/gcp-promql.ts | 2 ++ 2 files changed, 25 insertions(+) diff --git a/apps/ai/src/mcp/tools/gcp-promql.test.ts b/apps/ai/src/mcp/tools/gcp-promql.test.ts index 508558c24e..7e810f548b 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.test.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.test.ts @@ -5,6 +5,7 @@ import { GcpPromQlFailedError, GcpPromQlInvalidQueryError, GcpPromQlResult, + GcpPromQlTooManySeriesError, GcpPromQlUnavailableError, type GcpPromQlInstantRequest, type GcpPromQlRangeRequest, @@ -217,6 +218,28 @@ describe("query_gcp_promql", () => { }), ) + it.effect("asks for aggregation when the answer holds too many series", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + call( + "query_gcp_promql", + { query: "up", ...WINDOW }, + { + queryRange: () => + Effect.fail( + new GcpPromQlTooManySeriesError({ + message: "Aggregate them, with sum by.", + series: 900, + }), + ), + }, + ), + ) + assert.strictEqual(error._tag, "@maple/mcp/errors/McpInvalidInputError") + assert.include(error.message, "sum by") + }), + ) + it.effect("maps a deployment without a Google identity and an unreadable setup to their kinds", () => Effect.gen(function* () { const unavailable = yield* Effect.flip( diff --git a/apps/ai/src/mcp/tools/gcp-promql.ts b/apps/ai/src/mcp/tools/gcp-promql.ts index 188659d2d3..9178aa6c55 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.ts @@ -49,6 +49,8 @@ const toMcpError = message: `${error.message} Projects: ${error.projects.slice(0, 20).join(", ")}${error.projects.length > 20 ? ` and ${error.projects.length - 20} more` : ""}.`, parameter: projectParameter, }) + case "@maple/http/errors/GcpPromQlTooManySeriesError": + return new McpInvalidInputError({ message: error.message, parameter: "query" }) case "@maple/http/errors/IntegrationsValidationError": return new McpInvalidInputError({ message: error.message, parameter: projectParameter }) case "@maple/http/errors/IntegrationsNotFoundError": From e5c0f9d6d07c8465fd7477f29a94f9b715761d6b Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:34:59 +0200 Subject: [PATCH 3/6] fix(mcp): name bucket_seconds in step errors and list a metric's other resource types --- apps/ai/src/mcp/tools/gcp-promql.test.ts | 54 +++++++++++++++++++----- apps/ai/src/mcp/tools/gcp-promql.ts | 7 ++- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/ai/src/mcp/tools/gcp-promql.test.ts b/apps/ai/src/mcp/tools/gcp-promql.test.ts index 7e810f548b..f09e125a09 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.test.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.test.ts @@ -5,6 +5,7 @@ import { GcpPromQlFailedError, GcpPromQlInvalidQueryError, GcpPromQlResult, + GcpPromQlTooManyProjectsError, GcpPromQlTooManySeriesError, GcpPromQlUnavailableError, type GcpPromQlInstantRequest, @@ -218,25 +219,53 @@ describe("query_gcp_promql", () => { }), ) - it.effect("asks for aggregation when the answer holds too many series", () => + it.effect("points the agent at the parameter to change", () => + Effect.gen(function* () { + const failWith = (error: GcpPromQlTooManySeriesError | GcpPromQlTooManyProjectsError) => + Effect.flip( + call( + "query_gcp_promql", + { query: "up", ...WINDOW }, + { queryRange: () => Effect.fail(error) }, + ), + ) + const series = yield* failWith( + new GcpPromQlTooManySeriesError({ message: "Aggregate them.", series: 900 }), + ) + assert.deepInclude(series, { _tag: "@maple/mcp/errors/McpInvalidInputError", parameter: "query" }) + const projects = yield* failWith( + new GcpPromQlTooManyProjectsError({ + message: "Choose the projects to query.", + projects: Array.from({ length: 25 }, (_, index) => + Schema.decodeUnknownSync(GcpProjectId)(`shop-${String(index).padStart(2, "0")}`), + ), + }), + ) + assert.deepInclude(projects, { + _tag: "@maple/mcp/errors/McpInvalidInputError", + parameter: "projects", + }) + assert.include(projects.message, "shop-19 and 5 more.") + }), + ) + + it.effect("refuses a step too fine for the window in the tool's own words", () => Effect.gen(function* () { const error = yield* Effect.flip( call( "query_gcp_promql", - { query: "up", ...WINDOW }, { - queryRange: () => - Effect.fail( - new GcpPromQlTooManySeriesError({ - message: "Aggregate them, with sum by.", - series: 900, - }), - ), + query: "up", + start_time: "2026-09-10 12:00:00", + end_time: "2026-10-15 12:00:00", + bucket_seconds: 60, }, + {}, ), ) assert.strictEqual(error._tag, "@maple/mcp/errors/McpInvalidInputError") - assert.include(error.message, "sum by") + assert.include(error.message, "bucket_seconds") + assert.notInclude(error.message, "stepSeconds") }), ) @@ -249,7 +278,10 @@ describe("query_gcp_promql", () => { { queryRange: () => Effect.fail(new GcpPromQlUnavailableError({ message: "no key" })) }, ), ) - assert.strictEqual(unavailable._tag, "@maple/mcp/errors/McpUnavailableError") + assert.deepInclude(unavailable, { + _tag: "@maple/mcp/errors/McpUnavailableError", + capability: "gcp_live_query", + }) const failed = yield* Effect.flip( call( "query_gcp_promql", diff --git a/apps/ai/src/mcp/tools/gcp-promql.ts b/apps/ai/src/mcp/tools/gcp-promql.ts index 9178aa6c55..09fef79dad 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.ts @@ -268,7 +268,7 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { ...(resources.length > 1 ? [ doc.text( - `The \`monitored_resource\` matcher is required: this metric has ${resources.length} resource types (${resources.join(", ")}).`, + `The \`monitored_resource\` matcher is required: the starter query reads ${resources[0]}; the metric also exists for ${resources.slice(1).join(", ")}.`, ), ] : []), @@ -343,8 +343,11 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { Math.max(60, Math.floor(params.bucket_seconds ?? computeBucketSecondsForRange(st, et))), ) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.ai.kind": kind }) + // The request schema names the step by its HTTP name. const invalid = (error: Schema.SchemaError) => - new McpInvalidInputError({ message: error.message }) + new McpInvalidInputError({ + message: error.message.replaceAll("stepSeconds", "bucket_seconds"), + }) const fail = toMcpError("query_gcp_promql", "projects") const result = From 2ad948294a9dd3200d54db5c57678b3477a6c026 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:37:30 +0200 Subject: [PATCH 4/6] refactor(mcp): leaner PromQL tools after review Explicit bucket_seconds is validated, not clamped; one error tail for both query kinds; the describe output reuses the descriptor's fields; label values are quoted and descriptions capped; gauge distributions skip rate; chart series keep __name__ so none collide. --- apps/ai/src/mcp/tools/gcp-promql.test.ts | 7 ++ apps/ai/src/mcp/tools/gcp-promql.ts | 80 +++++++------------ .../components/promql-chart-rows.test.ts | 10 ++- .../renderers/components/promql-chart-rows.ts | 5 +- .../ai-elements/renderers/tool-renderer.tsx | 9 ++- packages/domain/src/mcp-outputs/gcp.ts | 15 ++-- 6 files changed, 58 insertions(+), 68 deletions(-) diff --git a/apps/ai/src/mcp/tools/gcp-promql.test.ts b/apps/ai/src/mcp/tools/gcp-promql.test.ts index f09e125a09..745eef492e 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.test.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.test.ts @@ -116,6 +116,13 @@ describe("starterQuery", () => { ) }) + it("reads a gauge distribution's buckets without rate", () => { + assert.strictEqual( + starterQuery({ ...metric, metricKind: "GAUGE", valueType: "DISTRIBUTION" }), + 'histogram_quantile(0.95, sum by (le) ({"run.googleapis.com/request_count_bucket"}))', + ) + }) + it("offers none for a STRING metric, which PromQL cannot read", () => { assert.isUndefined(starterQuery({ ...metric, metricKind: "GAUGE", valueType: "STRING" })) }) diff --git a/apps/ai/src/mcp/tools/gcp-promql.ts b/apps/ai/src/mcp/tools/gcp-promql.ts index 09fef79dad..bc45a1ac48 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.ts @@ -72,20 +72,6 @@ const toMcpError = } } -const decodeProject = (project: string | undefined) => - project === undefined - ? Effect.succeed(undefined) - : Schema.decodeUnknownEffect(GcpProjectId)(project).pipe( - Effect.mapError( - () => - new McpInvalidInputError({ - message: `\`${project}\` is not a Google Cloud project id.`, - parameter: "project", - example: 'project="my-project-123"', - }), - ), - ) - /** A query for a metric type's kind: rate for counters, avg for gauges, p95 for distributions. */ export const starterQuery = (metric: { readonly type: string @@ -100,7 +86,8 @@ export const starterQuery = (metric: { ? `, monitored_resource="${metric.monitoredResourceTypes[0]}"` : "" if (metric.valueType === "DISTRIBUTION") { - return `histogram_quantile(0.95, sum by (le) (rate({"${metric.type}_bucket"${resource}}[5m])))` + const buckets = `{"${metric.type}_bucket"${resource}}` + return `histogram_quantile(0.95, sum by (le) (${metric.metricKind === "GAUGE" ? buckets : `rate(${buckets}[5m])`}))` } const selector = `{"${metric.type}"${resource}}` return metric.metricKind === "GAUGE" ? `avg(${selector})` : `sum(rate(${selector}[5m]))` @@ -108,13 +95,13 @@ export const starterQuery = (metric: { const seriesName = (labels: Readonly>): string => { const entries = Object.entries(labels).filter(([key]) => key !== "__name__") - return `{${entries.map(([key, value]) => `${key}="${value}"`).join(", ")}}` + return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(", ")}}` } const formatValue = (value: number): string => value.toLocaleString("en-US", { maximumSignificantDigits: 4 }) const lastValue = (points: ReadonlyArray): number => - points.at(-1)?.[1] ?? Number.NEGATIVE_INFINITY + points.at(-1)?.[1] ?? -Number.MAX_VALUE export function registerGcpPromQlTools(server: McpToolRegistrar) { server.define({ @@ -151,7 +138,14 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { example: 'metric="run.googleapis.com/request_count"', }) } - const project = yield* decodeProject(params.project) + const project = params.project + if (project !== undefined && !Schema.is(GcpProjectId)(project)) { + return yield* new McpInvalidInputError({ + message: `\`${project}\` is not a Google Cloud project id.`, + parameter: "project", + example: 'project="my-project-123"', + }) + } const tenant = yield* CurrentMcpTenant const gcp = yield* GcpPromQlService yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) @@ -180,13 +174,7 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { Effect.map((descriptor) => { const query = starterQuery(descriptor) return { - type: descriptor.type, - metricKind: descriptor.metricKind, - valueType: descriptor.valueType, - unit: descriptor.unit, - description: descriptor.description, - monitoredResourceTypes: descriptor.monitoredResourceTypes, - labels: descriptor.labels, + ...descriptor, ...(query === undefined ? undefined : { starterQuery: query }), } }), @@ -248,7 +236,7 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { ["Value type", metric.valueType], ["Unit", metric.unit === "" ? "-" : metric.unit], ["Resource types", resources.join(", ")], - ["Description", metric.description], + ["Description", truncate(metric.description, 300)], ]), ...(metric.labels.length === 0 ? [] @@ -338,42 +326,34 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { const kind = params.kind ?? "timeseries" const query = params.query.trim() const projects = params.projects === undefined ? undefined : { projects: params.projects } - const stepSeconds = Math.min( - 86_400, - Math.max(60, Math.floor(params.bucket_seconds ?? computeBucketSecondsForRange(st, et))), - ) + const stepSeconds = params.bucket_seconds ?? computeBucketSecondsForRange(st, et) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.ai.kind": kind }) - // The request schema names the step by its HTTP name. - const invalid = (error: Schema.SchemaError) => - new McpInvalidInputError({ - message: error.message.replaceAll("stepSeconds", "bucket_seconds"), - }) const fail = toMcpError("query_gcp_promql", "projects") - const result = + const result = yield* ( kind === "instant" - ? yield* Schema.decodeUnknownEffect(GcpPromQlInstantRequest)({ + ? Schema.decodeUnknownEffect(GcpPromQlInstantRequest)({ query, time: et, ...projects, - }).pipe( - Effect.mapError(invalid), - Effect.flatMap((request) => - gcp.queryInstant(tenant.orgId, request).pipe(Effect.mapError(fail)), - ), - ) - : yield* Schema.decodeUnknownEffect(GcpPromQlRangeRequest)({ + }).pipe(Effect.flatMap((request) => gcp.queryInstant(tenant.orgId, request))) + : Schema.decodeUnknownEffect(GcpPromQlRangeRequest)({ query, startTime: st, endTime: et, stepSeconds, ...projects, - }).pipe( - Effect.mapError(invalid), - Effect.flatMap((request) => - gcp.queryRange(tenant.orgId, request).pipe(Effect.mapError(fail)), - ), - ) + }).pipe(Effect.flatMap((request) => gcp.queryRange(tenant.orgId, request))) + ).pipe( + Effect.mapError((error) => + Schema.isSchemaError(error) + ? // The request schema names the step by its HTTP name. + new McpInvalidInputError({ + message: error.message.replaceAll("stepSeconds", "bucket_seconds"), + }) + : fail(error), + ), + ) yield* Effect.annotateCurrentSpan("result.seriesCount", result.series.length) return { query, diff --git a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts index b83e38f4ec..04cc4879b1 100644 --- a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts +++ b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.test.ts @@ -14,10 +14,16 @@ describe("promQlChartRows", () => { { labels: { project_id: "shop-dev", service: "api" }, points: [[1_760_000_060, 5]] }, ]) expect(rows).toEqual([ - { bucket: "2025-10-09T08:53:20.000Z", series: { "project_id=shop-prod, service=api": 1 } }, + { + bucket: "2025-10-09T08:53:20.000Z", + series: { "__name__=x, project_id=shop-prod, service=api": 1 }, + }, { bucket: "2025-10-09T08:54:20.000Z", - series: { "project_id=shop-prod, service=api": 2, "project_id=shop-dev, service=api": 5 }, + series: { + "__name__=x, project_id=shop-prod, service=api": 2, + "project_id=shop-dev, service=api": 5, + }, }, ]) }) diff --git a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts index fb1e59f06b..f5c8d22147 100644 --- a/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts +++ b/apps/web/src/components/ai-elements/renderers/components/promql-chart-rows.ts @@ -2,8 +2,9 @@ import type { StructuredToolData } from "@maple/domain" type PromQlSeries = StructuredToolData<"query_gcp_promql">["series"][number] -const seriesKey = (labels: PromQlSeries["labels"]): string => { - const entries = Object.entries(labels).filter(([key]) => key !== "__name__") +/** A series' name in the chart and table: every label, `__name__` included, so no two collide. */ +export const seriesKey = (labels: PromQlSeries["labels"]): string => { + const entries = Object.entries(labels) return entries.length === 0 ? "value" : entries.map(([key, value]) => `${key}=${value}`).join(", ") } diff --git a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx index b28f9c9072..97854ec2d3 100644 --- a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx +++ b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx @@ -5,7 +5,7 @@ import { DataTable } from "./components/data-table" import { ErrorList } from "./components/error-list" import { LogList } from "./components/log-list" import { MetricsList } from "./components/metrics-list" -import { promQlChartRows } from "./components/promql-chart-rows" +import { promQlChartRows, seriesKey } from "./components/promql-chart-rows" import { QueryChart } from "./components/query-chart" import { SpanTree } from "./components/span-tree" import { StatCards } from "./components/stat-cards" @@ -187,9 +187,10 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { - Object.entries(row.series).map(([name, value]) => [name, String(value)]), - ), + rows: output.data.series.map((series) => [ + seriesKey(series.labels), + String(series.points[0]?.[1] ?? "-"), + ]), title: output.data.query, }} /> diff --git a/packages/domain/src/mcp-outputs/gcp.ts b/packages/domain/src/mcp-outputs/gcp.ts index ed1b631673..ad53927984 100644 --- a/packages/domain/src/mcp-outputs/gcp.ts +++ b/packages/domain/src/mcp-outputs/gcp.ts @@ -1,18 +1,13 @@ /** Output schemas for the live Google Cloud Monitoring tools (PromQL through Google's API). */ import { Schema } from "effect" -import { GcpPromQlFailure, GcpPromQlSeries } from "../http/gcp-promql" +import { GcpMetricDescriptor, GcpPromQlFailure, GcpPromQlSeries } from "../http/gcp-promql" +import { GcpProjectId } from "../primitives" import { OutputTimeRange } from "./shared" // find_gcp_metrics export const GcpMetricDescription = Schema.Struct({ - type: Schema.String, - metricKind: Schema.String, - valueType: Schema.String, - unit: Schema.String, - description: Schema.String, - monitoredResourceTypes: Schema.Array(Schema.String), - labels: Schema.Array(Schema.Struct({ key: Schema.String, description: Schema.String })), + ...GcpMetricDescriptor.fields, /** A query for query_gcp_promql. Absent for a value type PromQL cannot read (STRING). */ starterQuery: Schema.optionalKey(Schema.String), }) @@ -23,7 +18,7 @@ export const FindGcpMetricsOutput = Schema.Struct({ Schema.Struct({ text: Schema.String, /** The project whose metric list was searched. */ - projectId: Schema.String, + projectId: GcpProjectId, metrics: Schema.Array(Schema.String), /** More names matched than `metrics` holds. */ truncated: Schema.Boolean, @@ -45,6 +40,6 @@ export const QueryGcpPromQlOutput = Schema.Struct({ series: Schema.Array(GcpPromQlSeries), /** Series Google returned, before the cap. */ totalSeries: Schema.Number, - projects: Schema.Array(Schema.String), + projects: Schema.Array(GcpProjectId), failures: Schema.Array(GcpPromQlFailure), }) From c8cf25a7022f24affe0572b097750d56df343665 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:39:09 +0200 Subject: [PATCH 5/6] fix(mcp): bound PromQL points per series, show an instant's latest value A bucket_seconds too fine for the window widens to 500 points instead of a 400, which also bounds the chat payload. No project to read is unavailable, not a retry. Describe suggestions skip names Google would refuse; the instant lookback hint shows only on instant queries. --- apps/ai/src/mcp/tools/gcp-promql.test.ts | 48 ++++++++++++++++++------ apps/ai/src/mcp/tools/gcp-promql.ts | 27 +++++++++---- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/apps/ai/src/mcp/tools/gcp-promql.test.ts b/apps/ai/src/mcp/tools/gcp-promql.test.ts index 745eef492e..f050fed22a 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.test.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.test.ts @@ -256,19 +256,25 @@ describe("query_gcp_promql", () => { }), ) - it.effect("refuses a step too fine for the window in the tool's own words", () => + it.effect("widens a step too fine for the window and refuses one past a day", () => Effect.gen(function* () { - const error = yield* Effect.flip( - call( - "query_gcp_promql", - { - query: "up", - start_time: "2026-09-10 12:00:00", - end_time: "2026-10-15 12:00:00", - bucket_seconds: 60, + const seen: Array = [] + const month = { start_time: "2026-09-10 12:00:00", end_time: "2026-10-15 12:00:00" } + const answer = yield* call( + "query_gcp_promql", + { query: "up", ...month, bucket_seconds: 60 }, + { + queryRange: (_org, request) => { + seen.push(request) + return Effect.succeed(result([])) }, - {}, - ), + }, + ) + // 35 days in at most 500 points. + assert.strictEqual(seen[0]?.stepSeconds, 6_048) + assert.include(answer.text, "Step: 6048s") + const error = yield* Effect.flip( + call("query_gcp_promql", { query: "up", ...WINDOW, bucket_seconds: 100_000 }, {}), ) assert.strictEqual(error._tag, "@maple/mcp/errors/McpInvalidInputError") assert.include(error.message, "bucket_seconds") @@ -298,7 +304,12 @@ describe("query_gcp_promql", () => { Effect.fail( new GcpPromQlFailedError({ message: "Run the setup script again.", - failures: [], + failures: [ + { + scope: "projects/shop-prod", + message: "Run the setup script again.", + }, + ], }), ), }, @@ -306,6 +317,19 @@ describe("query_gcp_promql", () => { ) assert.strictEqual(failed._tag, "@maple/mcp/errors/McpNotReadyError") assert.include(failed.message, "setup script") + const empty = yield* Effect.flip( + call( + "query_gcp_promql", + { query: "up", ...WINDOW }, + { + queryRange: () => + Effect.fail( + new GcpPromQlFailedError({ message: "No project to read.", failures: [] }), + ), + }, + ), + ) + assert.strictEqual(empty._tag, "@maple/mcp/errors/McpUnavailableError") }), ) }) diff --git a/apps/ai/src/mcp/tools/gcp-promql.ts b/apps/ai/src/mcp/tools/gcp-promql.ts index bc45a1ac48..e8e7cd9c16 100644 --- a/apps/ai/src/mcp/tools/gcp-promql.ts +++ b/apps/ai/src/mcp/tools/gcp-promql.ts @@ -7,6 +7,7 @@ import { GcpMetricType, GcpPromQlInstantRequest, GcpPromQlRangeRequest, + gcpPromQlTimeMs, type IntegrationsNotFoundError, } from "@maple/domain/http" import { FindGcpMetricsOutput, QueryGcpPromQlOutput } from "@maple/domain/mcp-outputs" @@ -32,6 +33,8 @@ const WINDOW = P.timeWindow({ defaultHours: 1, maxHours: 42 * 24 }) const MAX_SERIES = 100 /** Series the model reads. */ const SHOWN_SERIES = 20 +/** Points per series in the output, at most: a finer `bucket_seconds` is widened to keep it. */ +const MAX_POINTS = 500 const DISTRIBUTION_SUFFIX = /_(bucket|count|sum)$/u const HINTS = { readOnly: true, openWorld: true } as const @@ -63,9 +66,11 @@ const toMcpError = case "@maple/http/errors/IntegrationsNotConnectedError": return new McpUnavailableError({ message: error.message, capability: "gcp_connection" }) // Every project failed; the message says why per project and what fixes it (mostly a - // setup-script run whose grant takes minutes to apply). + // setup-script run whose grant takes minutes to apply). No failures: no project to read. case "@maple/http/errors/GcpPromQlFailedError": - return new McpNotReadyError({ message: error.message, retryAfterSeconds: 300 }) + return error.failures.length === 0 + ? new McpUnavailableError({ message: error.message, capability: "gcp_connection" }) + : new McpNotReadyError({ message: error.message, retryAfterSeconds: 300 }) case "@maple/http/errors/IntegrationsUpstreamError": case "@maple/http/errors/IntegrationsPersistenceError": return new McpQueryError({ message: error.message, pipeName: tool, cause: error }) @@ -310,7 +315,7 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { ), ...WINDOW.fields, bucket_seconds: P.optionalNumber( - "Point spacing in seconds, 60 to 86400 (timeseries only; default: from the window, about 100 points)", + "Point spacing in seconds, 60 to 86400 (timeseries only; default: from the window, about 100 points; widened to keep at most 500 points)", ), projects: P.optionalList( "Only these Google Cloud project ids, at most 20 (default: every connected project)", @@ -326,7 +331,10 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { const kind = params.kind ?? "timeseries" const query = params.query.trim() const projects = params.projects === undefined ? undefined : { projects: params.projects } - const stepSeconds = params.bucket_seconds ?? computeBucketSecondsForRange(st, et) + const stepSeconds = Math.max( + params.bucket_seconds ?? computeBucketSecondsForRange(st, et), + Math.ceil((gcpPromQlTimeMs(et) - gcpPromQlTimeMs(st)) / 1000 / MAX_POINTS), + ) yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.ai.kind": kind }) const fail = toMcpError("query_gcp_promql", "projects") @@ -373,10 +381,11 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { const shown = output.series.slice(0, SHOWN_SERIES) // A distribution's `_bucket` name describes too. const metricType = /\{\s*"([^"]+)"/u.exec(output.query)?.[1] + const describable = metricType !== undefined && Schema.is(GcpMetricType)(metricType) const rows = shown.map((series) => { const values = series.points.map(([, value]) => value) const name = truncate(seriesName(series.labels), 120) - if (instant) return [name, values.length === 0 ? "-" : formatValue(values[0])] + if (instant) return [name, values.length === 0 ? "-" : formatValue(values[values.length - 1])] if (values.length === 0) return [name, "-", "-", "-", "-", "0"] return [ name, @@ -388,7 +397,7 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { ] }) const next: Array = [ - ...(metricType === undefined + ...(!describable ? [] : [ doc.next( @@ -437,7 +446,11 @@ export function registerGcpPromQlTools(server: McpToolRegistrar) { message: "Google returned no series.", hints: [ "The metric may have no data in this window: widen start_time, or check the name with find_gcp_metrics.", - "An instant query looks back 5 minutes; a sparse metric needs kind=timeseries or a range selector.", + ...(instant + ? [ + "An instant query looks back 5 minutes; a sparse metric needs kind=timeseries.", + ] + : []), ], }, } From 0f86f4e14f7c9d111492aede81e281146bdcf47a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 23:32:46 +0200 Subject: [PATCH 6/6] fix(ci): resolve the bundle budget merge --- apps/web/perf/check-bundle-budget.ts | 32 ++++++---------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/apps/web/perf/check-bundle-budget.ts b/apps/web/perf/check-bundle-budget.ts index 7d91f5d91e..5ba7274468 100644 --- a/apps/web/perf/check-bundle-budget.ts +++ b/apps/web/perf/check-bundle-budget.ts @@ -96,40 +96,22 @@ const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) // carries, the pill in the persistent top bar with its live clock, and the // hook's query and seen-flag atoms. The popover's panel and pointer are small // and render only while open, so nothing there is worth a lazy chunk. -// 699.5 KB from the Google Cloud connectors (#1321, 2026-10-15): ~2.5 KB of +// 698.5 KB from the Google Cloud connectors (#1321, 2026-10-15): ~2.5 KB of // startup measured in CI against main's 694.6 — the connector group's endpoints -// and schemas on the v2 contract every page's client carries. -<<<<<<< HEAD -// 698.5 KB from the Google Cloud integration card (#1334, 2026-10-15): ~0.9 KB +// and schemas on the v2 contract every page's client carries. Each Google Cloud +// step below keeps ~1 KB of headroom, since CI measures the PR merged with main. +// 699.5 KB from the Google Cloud integration card (#1334, 2026-10-15): ~0.9 KB // of startup measured in CI against #1333's 697.3 from the card and its // contract additions. -<<<<<<< HEAD -// 703.5 KB from the Infrastructure → Google Cloud page (#1336, 2026-10-15): +// 701.5 KB from the Infrastructure → Google Cloud page (#1336, 2026-10-15): // ~2 KB of startup measured in CI against #1335's 698.3 — the inventory // schemas on the contract, the connector status rules the sidebar and the // integrations hub read, and the Infrastructure nav entry with its icon. The // /infra/gcp pages stay in their route chunks. -<<<<<<< HEAD -// 701.5 KB from the PromQL query contract (#1475, 2026-10-15), measured in CI at 701.3. -<<<<<<< HEAD -// 702.5 KB from the connection access settings (#1477, 2026-10-15), measured in CI at 702.1. -<<<<<<< HEAD +// 702.5 KB from the PromQL query contract (#1475, 2026-10-15), measured in CI at 701.3. +// 703.5 KB from the connection access settings (#1477, 2026-10-15), measured in CI at 702.1. // 706 KB from the PromQL dashboard widget (#1480, 2026-10-15), measured in CI at 704.4. const maxGzipBytes = 706 * 1024 -======= -const maxGzipBytes = 703.5 * 1024 -======= -const maxGzipBytes = 703.5 * 1024 -======= -const maxGzipBytes = 703.5 * 1024 -======= -======= ->>>>>>> origin/feat/gcp-metrics-poller -const maxGzipBytes = 699.5 * 1024 ->>>>>>> origin/feat/gcp-dashboard-template ->>>>>>> origin/feat/gcp-infra-page ->>>>>>> origin/feat/gcp-promql-query ->>>>>>> origin/feat/gcp-live-query-access const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB` // Anything lazy-only: chat, replay, and every dev-only lab surface. The