diff --git a/.changeset/mcp-promql-tiles.md b/.changeset/mcp-promql-tiles.md new file mode 100644 index 0000000000..5f7b54caf6 --- /dev/null +++ b/.changeset/mcp-promql-tiles.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +feat: MCP dashboard tools read and write PromQL tiles and PROMETHEUS_LABEL filters when PromQL is enabled, and hide but keep them when it is disabled diff --git a/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts index d315631232..55f7c7e5ad 100644 --- a/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts @@ -1,17 +1,28 @@ -import { DisplayType } from '@hyperdx/common-utils/dist/types'; -import mongoose from 'mongoose'; +import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types'; import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils'; import Dashboard from '@/models/dashboard'; +import { Source } from '@/models/source'; import { setupDashboardTests } from './setup'; -// MCP cannot edit PromQL tiles until HDX-5302, so it hides them on read and -// keeps them on save. Remove with that work. -describe('MCP Dashboard Tools - PromQL tiles', () => { +describe('MCP Dashboard Tools - PromQL tiles and label filters', () => { const ctx = setupDashboardTests(); + let promqlSourceId: string; - const promqlTile = { + beforeEach(async () => { + const source = await Source.create({ + kind: SourceKind.Promql, + team: ctx.team._id, + connection: ctx.connection._id, + name: 'Metrics', + from: { databaseName: 'default', tableName: 'promql_metrics' }, + timestampValueExpression: 'timestamp', + }); + promqlSourceId = source._id.toString(); + }); + + const storedPromqlTile = () => ({ id: 'promql-tile', x: 0, y: 0, @@ -21,12 +32,12 @@ describe('MCP Dashboard Tools - PromQL tiles', () => { configType: 'promql', name: 'PromQL', displayType: DisplayType.Line, - source: new mongoose.Types.ObjectId().toString(), + source: promqlSourceId, promqlExpression: [{ expression: 'up' }], }, - }; + }); - const markdownTile = { + const storedMarkdownTile = { id: 'markdown-tile', x: 6, y: 0, @@ -42,48 +53,403 @@ describe('MCP Dashboard Tools - PromQL tiles', () => { }, }; - const createDashboard = () => + const storedLabelFilter = () => ({ + id: 'label-filter', + type: 'PROMETHEUS_LABEL' as const, + name: 'Job', + source: promqlSourceId, + label: 'job', + isBroadcastEnabled: false as const, + isVariableEnabled: true as const, + }); + + const createStoredDashboard = () => new Dashboard({ name: 'PromQL dashboard', - tiles: [promqlTile, markdownTile], + tiles: [storedPromqlTile(), storedMarkdownTile], + filters: [storedLabelFilter()], team: ctx.team._id, }).save(); - it('hides PromQL tiles from clickstack_get_dashboard', async () => { - const dashboard = await createDashboard(); + const parseOutput = (result: Awaited>) => + JSON.parse(getFirstText(result)); + + describe('with PromQL enabled', () => { + beforeEach(() => ctx.reconnect({ promqlEnabled: true })); - const result = await callTool(ctx.client!, 'clickstack_get_dashboard', { - id: dashboard._id.toString(), + const promqlTileInputs = () => { + const base = { configType: 'promql', sourceId: promqlSourceId }; + const rate = 'sum by (job) (rate(http_requests_total[$__rate_interval]))'; + return [ + { + name: 'Line', + config: { + ...base, + displayType: 'line', + expressions: [{ expression: rate, alias: 'Requests' }], + legendTemplate: '{{job}}', + granularity: '1 minute', + fillNulls: false, + alignDateRangeToGranularity: false, + seriesLimit: 5, + compareToPreviousPeriod: true, + lineInterpolation: 'step', + }, + }, + { + name: 'Stacked bar', + config: { + ...base, + displayType: 'stacked_bar', + expressions: [{ expression: rate }, { expression: 'sum(up)' }], + seriesLimit: 3, + }, + }, + { + name: 'Stacked line', + config: { + ...base, + displayType: 'stacked_line', + expressions: [{ expression: rate }], + lineInterpolation: 'linear', + }, + }, + { + name: 'Table', + config: { + ...base, + displayType: 'table', + expressions: [{ expression: 'up', queryType: 'instant' }], + }, + }, + { + name: 'Number', + config: { + ...base, + displayType: 'number', + expressions: [ + { expression: 'sum(up)', queryType: 'range', reducer: 'max' }, + ], + numberFormat: { output: 'number', mantissa: 1 }, + color: 'chart-green', + colorRules: [{ operator: 'lt', value: 1, color: 'chart-error' }], + backgroundChart: { type: 'area' }, + }, + }, + { + name: 'Pie', + config: { + ...base, + displayType: 'pie', + expressions: [ + { expression: 'sum by (job) (up)', reducer: 'lastNotNull' }, + ], + legendTemplate: '{{job}}', + limit: 4, + }, + }, + { + name: 'Bar', + config: { + ...base, + displayType: 'bar', + expressions: [{ expression: 'sum by (job) (up)' }], + limit: 0, + }, + }, + { + name: 'Heatmap', + config: { + ...base, + displayType: 'heatmap', + expressions: [ + { + expression: + 'sum by (le) (rate(http_request_duration_seconds_bucket[$__rate_interval]))', + }, + ], + heatmapMode: 'histogram', + heatmapScaleType: 'log', + legendTemplate: '{{le}}', + }, + }, + ]; + }; + + it('round-trips every display type through save, get and save', async () => { + const inputTiles = promqlTileInputs(); + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'PromQL dashboard', + tiles: inputTiles, + }); + expect(created.isError).toBeFalsy(); + const { id } = parseOutput(created); + const storedBefore = (await Dashboard.findById(id).lean())?.tiles; + + const read = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id, + }); + const readTiles = parseOutput(read).tiles; + for (const input of inputTiles) { + const readTile = readTiles.find( + (t: { name: string }) => t.name === input.name, + ); + expect(readTile.config).toMatchObject(input.config); + } + + const saved = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id, + name: 'PromQL dashboard', + tiles: readTiles, + }); + expect(saved.isError).toBeFalsy(); + const storedAfter = (await Dashboard.findById(id).lean())?.tiles; + expect(storedAfter).toEqual(storedBefore); }); - expect(result.isError).toBeFalsy(); - const output = JSON.parse(getFirstText(result)); - expect(output.tiles.map((t: { id: string }) => t.id)).toEqual([ - 'markdown-tile', - ]); - }); + it('round-trips a PROMETHEUS_LABEL filter', async () => { + const filter = { + type: 'PROMETHEUS_LABEL', + name: 'Job', + sourceId: promqlSourceId, + label: 'job', + match: 'up{env="prod"}', + variableName: 'job', + maxSelections: 1, + }; + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Filtered', + tiles: [], + filters: [filter], + }); + expect(created.isError).toBeFalsy(); + const { id, filters } = parseOutput(created); + expect(filters).toEqual([ + expect.objectContaining({ + ...filter, + isBroadcastEnabled: false, + isVariableEnabled: true, + }), + ]); - it('keeps PromQL tiles when clickstack_save_dashboard updates the dashboard', async () => { - const dashboard = await createDashboard(); + const saved = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id, + name: 'Filtered', + tiles: [], + filters, + }); + expect(saved.isError).toBeFalsy(); + expect(parseOutput(saved).filters).toEqual(filters); + }); - const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { - id: dashboard._id.toString(), - name: 'PromQL dashboard', - tiles: [ - { - id: 'markdown-tile', - name: 'Updated notes', - config: { displayType: 'markdown', markdown: 'Updated' }, + it('rejects a PromQL tile on a source that is not a promql source', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Wrong source', + tiles: [ + { + name: 'Up', + config: { + configType: 'promql', + displayType: 'line', + sourceId: ctx.traceSource._id.toString(), + expressions: [{ expression: 'up' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain(ctx.traceSource._id.toString()); + }); + + it('deletes a PromQL tile left out of an update', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: dashboard._id.toString(), + name: 'PromQL dashboard', + tiles: [ + { + id: 'markdown-tile', + name: 'Notes', + config: { displayType: 'markdown', markdown: 'Notes' }, + }, + ], + filters: [], + }); + + expect(result.isError).toBeFalsy(); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.tiles.map(t => t.id)).toEqual(['markdown-tile']); + expect(stored?.filters).toEqual([]); + }); + + it('patches a PromQL tile', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: dashboard._id.toString(), + tileId: 'promql-tile', + tile: { + config: { + configType: 'promql', + displayType: 'number', + sourceId: promqlSourceId, + expressions: [{ expression: 'sum(up)', reducer: 'min' }], + }, }, - ], + }); + + expect(result.isError).toBeFalsy(); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.tiles[0].config).toMatchObject({ + configType: 'promql', + displayType: DisplayType.Number, + name: 'PromQL', + promqlExpression: [{ expression: 'sum(up)', reducer: 'min' }], + }); + }); + + it('warns about a variable the dashboard does not declare', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Typo', + tiles: [ + { + name: 'Up', + config: { + configType: 'promql', + displayType: 'line', + sourceId: promqlSourceId, + expressions: [{ expression: 'up{job=~"$jbo"}' }], + }, + }, + ], + filters: [ + { + type: 'PROMETHEUS_LABEL', + name: 'Job', + sourceId: promqlSourceId, + label: 'job', + variableName: 'job', + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const { warnings } = parseOutput(result); + expect(warnings).toEqual([expect.stringContaining('jbo')]); + }); + }); + + describe('with PromQL disabled', () => { + beforeEach(() => ctx.reconnect({ promqlEnabled: false })); + + it('hides PromQL tiles and label filters from clickstack_get_dashboard', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: dashboard._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = parseOutput(result); + expect(output.tiles.map((t: { id: string }) => t.id)).toEqual([ + 'markdown-tile', + ]); + expect(output.filters).toEqual([]); + }); + + it('keeps hidden tiles and label filters when clickstack_save_dashboard updates the dashboard', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: dashboard._id.toString(), + name: 'PromQL dashboard', + tiles: [ + { + id: 'markdown-tile', + name: 'Updated notes', + config: { displayType: 'markdown', markdown: 'Updated' }, + }, + ], + filters: [ + { + type: 'STATIC_LIST', + name: 'Env', + options: ['prod'], + variableName: 'env', + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.tiles.map(t => t.id)).toEqual([ + 'markdown-tile', + 'promql-tile', + ]); + expect(stored?.tiles[1].config).toEqual(storedPromqlTile().config); + expect(stored?.filters?.map(f => f.type)).toEqual([ + 'STATIC_LIST', + 'PROMETHEUS_LABEL', + ]); + expect(stored?.filters?.[1]).toMatchObject(storedLabelFilter()); + }); + + it('rejects a filter whose variable name a hidden label filter already uses', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: dashboard._id.toString(), + name: 'PromQL dashboard', + tiles: [], + filters: [{ type: 'STATIC_LIST', name: 'Job', options: ['checkout'] }], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('"$Job" is already used'); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.filters).toEqual([ + expect.objectContaining(storedLabelFilter()), + ]); + expect(stored?.tiles).toHaveLength(2); + }); + + it('rejects a PromQL tile', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'PromQL off', + tiles: [ + { + name: 'Up', + config: { + configType: 'promql', + displayType: 'line', + sourceId: promqlSourceId, + expressions: [{ expression: 'up' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(await Dashboard.countDocuments({ team: ctx.team._id })).toBe(0); }); - expect(result.isError).toBeFalsy(); - const stored = await Dashboard.findById(dashboard._id).lean(); - expect(stored?.tiles.map(t => t.id)).toEqual([ - 'markdown-tile', - 'promql-tile', - ]); - expect(stored?.tiles[1].config).toEqual(promqlTile.config); + it('does not patch a hidden PromQL tile', async () => { + const dashboard = await createStoredDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: dashboard._id.toString(), + tileId: 'promql-tile', + tile: { + config: { displayType: 'markdown', markdown: 'Overwritten' }, + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Tile not found'); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.tiles[0].config).toEqual(storedPromqlTile().config); + }); }); }); diff --git a/packages/api/src/mcp/__tests__/dashboards/setup.ts b/packages/api/src/mcp/__tests__/dashboards/setup.ts index a3326c00f6..8064da35d0 100644 --- a/packages/api/src/mcp/__tests__/dashboards/setup.ts +++ b/packages/api/src/mcp/__tests__/dashboards/setup.ts @@ -29,6 +29,15 @@ export function setupDashboardTests() { traceSource: null as any, connection: null as any, client: null as Client | null, + /** Replaces `client` with one whose MCP context has these overrides. */ + reconnect: async (overrides: Partial): Promise => { + await ctx.client?.close(); + ctx.client = await createTestClient({ + teamId: ctx.team._id.toString(), + userId: ctx.user._id.toString(), + ...overrides, + }); + }, }; beforeAll(async () => { diff --git a/packages/api/src/mcp/__tests__/dashboards/validation.test.ts b/packages/api/src/mcp/__tests__/dashboards/validation.test.ts index f240bf13b5..260e5fd650 100644 --- a/packages/api/src/mcp/__tests__/dashboards/validation.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/validation.test.ts @@ -2,9 +2,9 @@ import { DASHBOARD_VARIABLE_NAME_MAX_LENGTH } from '@hyperdx/common-utils/dist/t import mongoose from 'mongoose'; import { - mcpFiltersParam, - mcpPatchDashboardSchema, - mcpTilesParam, + buildMcpFiltersParam, + buildMcpPatchDashboardSchema, + buildMcpTilesParam, } from '@/mcp/tools/dashboards/schemas'; import { getFilterVariableWarnings, @@ -21,6 +21,10 @@ import type { const connectionId = new mongoose.Types.ObjectId().toString(); const sourceId = new mongoose.Types.ObjectId().toString(); +const mcpTilesParam = buildMcpTilesParam(false); +const mcpFiltersParam = buildMcpFiltersParam(false); +const mcpPatchDashboardSchema = buildMcpPatchDashboardSchema(false); + describe('metric tile schema', () => { it('accepts exponential histograms and defaults their value expression', () => { const parsed = mcpTilesParam.parse([ diff --git a/packages/api/src/mcp/tools/dashboards/getDashboard.ts b/packages/api/src/mcp/tools/dashboards/getDashboard.ts index e5eac6714c..a18f09952b 100644 --- a/packages/api/src/mcp/tools/dashboards/getDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/getDashboard.ts @@ -6,6 +6,7 @@ import { getDashboards } from '@/controllers/dashboard'; import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError, validateObjectId } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import Dashboard from '@/models/dashboard'; import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards'; @@ -61,7 +62,7 @@ export function registerGetDashboard({ return mcpUserError('Dashboard not found'); } const externalDashboard = convertToExternalDashboard(dashboard, { - hidePromqlTiles: true, + hidePromql: !isPromqlEnabled(context), }); return { content: [ diff --git a/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts index 0857a8b5e2..f9a4d2632f 100644 --- a/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts +++ b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import Dashboard from '@/models/dashboard'; import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards'; import { objectIdSchema } from '@/utils/zod'; @@ -49,7 +50,7 @@ export function registerGetDashboardTile({ } const externalDashboard = convertToExternalDashboard(dashboard, { - hidePromqlTiles: true, + hidePromql: !isPromqlEnabled(context), }); const tile = externalDashboard.tiles.find(t => t.id === tileId); if (!tile) { diff --git a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts index 77b6f53ba3..5e43adfd10 100644 --- a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts @@ -1,3 +1,4 @@ +import { isPromqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import { uniq } from 'lodash'; import * as config from '@/config'; @@ -5,6 +6,7 @@ import { recordDashboardOnboardingIfHasTiles } from '@/controllers/dashboard'; import { IDEMPOTENT_DESTRUCTIVE_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import Dashboard from '@/models/dashboard'; import { cleanupDashboardAlerts, @@ -16,7 +18,7 @@ import { import type { ExternalDashboardTileWithId } from '@/utils/zod'; import { externalDashboardTileSchemaWithId } from '@/utils/zod'; -import { mcpPatchDashboardSchema } from './schemas'; +import { buildMcpPatchDashboardSchema } from './schemas'; import { getRawSqlMissingSourceError, getRawSqlTileMacroWarnings, @@ -29,6 +31,7 @@ export function registerPatchDashboard({ }: ToolRegistrar): void { const { teamId, userId } = context; const frontendUrl = config.FRONTEND_URL; + const promqlEnabled = isPromqlEnabled(context); registerTool( 'clickstack_patch_dashboard', @@ -45,7 +48,7 @@ export function registerPatchDashboard({ 'tile are permanently deleted. ' + 'Use clickstack_get_dashboard_tile to inspect a tile before patching it. ' + 'IMPORTANT: After patching a tile, run clickstack_query_tile to confirm the query still works.', - inputSchema: mcpPatchDashboardSchema, + inputSchema: buildMcpPatchDashboardSchema(promqlEnabled), }, async ({ dashboardId, name, tags, tileId, tile: inputTile }) => { // Cross-field validation (kept in handler so the inputSchema @@ -98,17 +101,20 @@ export function registerPatchDashboard({ // untouched tiles are never round-tripped through the external // converter (which would strip orphaned container refs from // unrelated tiles). - const internalTiles = - (existingDashboard.tiles as { id: string }[]) ?? []; - const existingIdx = internalTiles.findIndex(t => t.id === tileId); + const internalTiles = existingDashboard.tiles ?? []; + // With PromQL disabled, PromQL tiles are hidden from reads, so they + // cannot be patched either. + const existingIdx = internalTiles.findIndex( + t => + t.id === tileId && + (promqlEnabled || !isPromqlSavedChartConfig(t.config)), + ); if (existingIdx === -1) { // Build a human-readable list of available tile IDs. // Convert only for the error message (read-only, no write-back). const externalDashboard = convertToExternalDashboard( existingDashboard, - { - hidePromqlTiles: true, - }, + { hidePromql: !promqlEnabled }, ); return mcpUserError( `Tile not found: ${tileId}. Available tile IDs: ${externalDashboard.tiles.map(t => `${t.id} (${t.name})`).join(', ')}`, diff --git a/packages/api/src/mcp/tools/dashboards/queryTile.ts b/packages/api/src/mcp/tools/dashboards/queryTile.ts index 0ef97eb39d..b6cd385b57 100644 --- a/packages/api/src/mcp/tools/dashboards/queryTile.ts +++ b/packages/api/src/mcp/tools/dashboards/queryTile.ts @@ -4,6 +4,7 @@ import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import { parseTimeRange, runConfigTile } from '@/mcp/tools/query/helpers'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import Dashboard from '@/models/dashboard'; import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards'; import { objectIdSchema } from '@/utils/zod'; @@ -76,7 +77,7 @@ export function registerQueryTile({ } const externalDashboard = convertToExternalDashboard(dashboard, { - hidePromqlTiles: true, + hidePromql: !isPromqlEnabled(context), }); const tile = externalDashboard.tiles.find(t => t.id === tileId); if (!tile) { diff --git a/packages/api/src/mcp/tools/dashboards/queryTiles.ts b/packages/api/src/mcp/tools/dashboards/queryTiles.ts index c0d33ebbda..3c2575fb2f 100644 --- a/packages/api/src/mcp/tools/dashboards/queryTiles.ts +++ b/packages/api/src/mcp/tools/dashboards/queryTiles.ts @@ -5,6 +5,7 @@ import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import { parseTimeRange, runConfigTile } from '@/mcp/tools/query/helpers'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import { runWithTimeout } from '@/mcp/utils/timeout'; import Dashboard from '@/models/dashboard'; import { @@ -225,7 +226,7 @@ export function registerQueryTiles({ } const externalDashboard = convertToExternalDashboard(dashboard, { - hidePromqlTiles: true, + hidePromql: !isPromqlEnabled(context), }); const allTiles = externalDashboard.tiles; diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 3ac9dc7169..cdaa34beac 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -1,3 +1,8 @@ +import { + getFilterVariableName, + isFilterVariableEnabled, + isPrometheusLabelFilter, +} from '@hyperdx/common-utils/dist/filters'; import { isPromqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import type { DashboardContainer } from '@hyperdx/common-utils/dist/types'; import { uniq } from 'lodash'; @@ -9,6 +14,7 @@ import { recordDashboardOnboardingIfHasTiles } from '@/controllers/dashboard'; import { UPSERT_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { formatZodIssues, mcpUserError } from '@/mcp/utils/errors'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import Dashboard, { IDashboard } from '@/models/dashboard'; import { cleanupDashboardAlerts, @@ -24,7 +30,11 @@ import type { ExternalDashboardTileWithId } from '@/utils/zod'; import { objectIdSchema, tagsSchema } from '@/utils/zod'; import type { McpDashboardFilter } from './schemas'; -import { mcpContainersParam, mcpFiltersParam, mcpTilesParam } from './schemas'; +import { + buildMcpFiltersParam, + buildMcpTilesParam, + mcpContainersParam, +} from './schemas'; import { getFilterVariableWarnings, getRawSqlMissingSourceError, @@ -39,6 +49,7 @@ export function registerSaveDashboard({ }: ToolRegistrar): void { const { teamId, userId } = context; const frontendUrl = config.FRONTEND_URL; + const promqlEnabled = isPromqlEnabled(context); registerTool( 'clickstack_save_dashboard', @@ -60,12 +71,12 @@ export function registerSaveDashboard({ 'Dashboard ID. Omit to create a new dashboard, provide to update an existing one.', ), name: z.string().describe('Dashboard name'), - tiles: mcpTilesParam, + tiles: buildMcpTilesParam(promqlEnabled), tags: tagsSchema.describe( 'Labels for organizing the dashboard; clickstack_search_dashboards can filter by them.', ), containers: mcpContainersParam.optional(), - filters: mcpFiltersParam.optional(), + filters: buildMcpFiltersParam(promqlEnabled).optional(), }), }, async ({ @@ -81,6 +92,7 @@ export function registerSaveDashboard({ teamId, userId, frontendUrl, + promqlEnabled, name, inputTiles, tags, @@ -92,6 +104,7 @@ export function registerSaveDashboard({ teamId, userId, frontendUrl, + promqlEnabled, dashboardId, name, inputTiles, @@ -138,6 +151,7 @@ async function createDashboard({ teamId, userId, frontendUrl, + promqlEnabled, name, inputTiles, tags, @@ -147,6 +161,7 @@ async function createDashboard({ teamId: string; userId: string | undefined; frontendUrl: string | undefined; + promqlEnabled: boolean; name: string; inputTiles: unknown[]; tags: string[] | undefined; @@ -210,7 +225,7 @@ async function createDashboard({ recordDashboardOnboardingIfHasTiles(userId, newDashboard.tiles); const externalDashboard = convertToExternalDashboard(newDashboard, { - hidePromqlTiles: true, + hidePromql: !promqlEnabled, }); return { content: [ @@ -242,6 +257,7 @@ async function updateDashboard({ teamId, userId, frontendUrl, + promqlEnabled, dashboardId, name, inputTiles, @@ -252,6 +268,7 @@ async function updateDashboard({ teamId: string; userId: string | undefined; frontendUrl: string | undefined; + promqlEnabled: boolean; dashboardId: string; name: string; inputTiles: unknown[]; @@ -323,17 +340,21 @@ async function updateDashboard({ tilesWithId, existingTileIds, ); - // MCP reads hide PromQL tiles, so the agent's tile list never contains them. - // Keep them rather than delete tiles it could not see. A request tile that - // reuses a hidden tile's id replaces it. Remove with HDX-5302. + // With PromQL disabled, reads hide PromQL tiles and PROMETHEUS_LABEL + // filters, so the agent's lists never contain them. Keep them rather than + // delete what it could not see. A request tile that reuses a hidden tile's + // id replaces it. const requestTileIds = new Set(requestTiles.map(tile => tile.id)); - const internalTiles = [ - ...requestTiles, - ...(existingDashboard.tiles ?? []).filter( - tile => - isPromqlSavedChartConfig(tile.config) && !requestTileIds.has(tile.id), - ), - ]; + const internalTiles = promqlEnabled + ? requestTiles + : [ + ...requestTiles, + ...(existingDashboard.tiles ?? []).filter( + tile => + isPromqlSavedChartConfig(tile.config) && + !requestTileIds.has(tile.id), + ), + ]; // Typed as `Partial` (the canonical Mongo doc shape) so // misnamed or wrong-shape fields fail at compile time, mirroring the @@ -346,10 +367,35 @@ async function updateDashboard({ }; if (filters !== undefined) { - setPayload.filters = convertExternalFiltersToInternal( + const requestFilters = convertExternalFiltersToInternal( filters, existingFilterIds, ); + const requestFilterIds = new Set(requestFilters.map(filter => filter.id)); + const hiddenFilters = promqlEnabled + ? [] + : (existingDashboard.filters ?? []).filter( + filter => + isPrometheusLabelFilter(filter) && !requestFilterIds.has(filter.id), + ); + + // The body schema checks that variable names are unique only across the + // request, so check the hidden filters added back here. + const requestVariableNames = new Set( + requestFilters.filter(isFilterVariableEnabled).map(getFilterVariableName), + ); + const clashingName = hiddenFilters + .filter(isFilterVariableEnabled) + .map(getFilterVariableName) + .find(name => name !== undefined && requestVariableNames.has(name)); + if (clashingName !== undefined) { + return mcpUserError( + `Variable name "$${clashingName}" is already used by a filter this ` + + 'tool cannot show. Choose a different variableName.', + ); + } + + setPayload.filters = [...requestFilters, ...hiddenFilters]; } const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({ @@ -388,7 +434,7 @@ async function updateDashboard({ recordDashboardOnboardingIfHasTiles(userId, updatedDashboard.tiles); const externalDashboard = convertToExternalDashboard(updatedDashboard, { - hidePromqlTiles: true, + hidePromql: !promqlEnabled, }); return { content: [ diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index bd678a768e..5695a41836 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -12,10 +12,18 @@ import { DASHBOARD_VARIABLE_NAME_PATTERN_ANCHORED, DashboardContainerSchema, DashboardFilterType, + HeatmapScaleTypeSchema, LineInterpolationSchema, + MAX_LEGEND_TEMPLATE_LENGTH, + MAX_PROMQL_EXPRESSIONS, MetricsDataType, NumberTileColorConditionSchema, + PromqlHeatmapModeSchema, + PromqlLabelDashboardFilterSchema, + PromqlQueryTypeSchema, + PromqlReducerSchema, SearchConditionTrimmedLanguageSchema, + SQLIntervalSchema, StaticListDashboardFilterSchema, } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; @@ -1272,6 +1280,150 @@ GROUP BY ServiceName, ts .describe('Raw SQL tile query and display settings.'), }); +const PROMQL_TIME_CHARTS = '"line", "stacked_bar" or "stacked_line"'; + +const promqlOnlyFor = (displayTypes: string) => + `Valid only when displayType is ${displayTypes}, ignored otherwise.`; + +const mcpPromqlExpressionSchema = z.object({ + expression: z + .string() + .max(10000) + .describe( + 'PromQL expression. Supports dashboard variables in label matchers ' + + '(e.g. {job=~"$job"}) and the $__interval, $__rate_interval and ' + + '$__range macros. Example: ' + + 'sum by (job) (rate(http_requests_total[$__rate_interval]))', + ), + alias: z + .string() + .max(10000) + .optional() + .describe('Legend label for this series.'), + queryType: PromqlQueryTypeSchema.optional().describe( + '"instant" evaluates the expression once at the end of the time range; ' + + `"range" (the default) evaluates it over the range. ${promqlOnlyFor('"table", "number", "pie" or "bar"')}`, + ), + reducer: PromqlReducerSchema.optional().describe( + 'How a range query reduces each series to a single value. ' + + promqlOnlyFor('"number", "pie" or "bar"'), + ), +}); + +// One flat config for every display type, like the save_source schema: the +// external API tile schema applies the per-display-type rules on save. +const mcpPromqlTileSchema = mcpTileLayoutSchema.extend({ + config: z + .object({ + configType: z + .literal('promql') + .describe('Marks this as a PromQL tile. Requires a promql source.'), + displayType: z.enum([ + 'line', + 'stacked_bar', + 'stacked_line', + 'table', + 'number', + 'pie', + 'bar', + 'heatmap', + ]), + sourceId: z + .string() + .describe( + 'ID of a source whose kind is "promql" – call clickstack_list_sources', + ), + expressions: z + .array(mcpPromqlExpressionSchema) + .min(1) + .max(MAX_PROMQL_EXPRESSIONS) + .describe( + 'PromQL queries. Line and stacked charts plot every expression; ' + + 'other display types query only the first.', + ), + granularity: z + .union([SQLIntervalSchema, z.literal('auto')]) + .optional() + .describe( + 'Step of range queries and the value of $__interval, as " " ' + + 'with unit second, minute, hour or day (e.g. "1 minute"), or "auto". ' + + 'Omit for auto.', + ), + numberFormat: mcpNumberFormatSchema + .optional() + .describe(tileLevelNumberFormatDescription), + legendTemplate: z + .string() + .max(MAX_LEGEND_TEMPLATE_LENGTH) + .optional() + .describe( + 'Series name built from label values, e.g. "{{job}} {{instance}}". ' + + 'Ignored when displayType is "table" or "number".', + ), + fillNulls: z + .boolean() + .optional() + .describe( + `${timeChartFillNullsDescription} ${promqlOnlyFor(PROMQL_TIME_CHARTS)}`, + ), + alignDateRangeToGranularity: z + .boolean() + .optional() + .describe( + `${timeChartAlignDateRangeDescription} ${promqlOnlyFor(PROMQL_TIME_CHARTS)}`, + ), + seriesLimit: seriesLimitSchema.describe( + 'Maximum number of series to show. Omit for the default cap; 0 means ' + + `unlimited. ${promqlOnlyFor(PROMQL_TIME_CHARTS)}`, + ), + compareToPreviousPeriod: z + .boolean() + .optional() + .describe( + 'Overlay the previous period as a dashed comparison series. ' + + promqlOnlyFor('"line"'), + ), + lineInterpolation: LineInterpolationSchema.optional().describe( + 'How points are connected: "smooth", "linear", or "step". ' + + promqlOnlyFor('"line" or "stacked_line"'), + ), + color: ChartPaletteTokenSchema.optional().describe( + `${numberTileColorDescription} ${promqlOnlyFor('"number"')}`, + ), + colorRules: z + .array(NumberTileColorConditionSchema) + .max(10) + .optional() + .describe( + `${numberTileColorRulesDescription} ${promqlOnlyFor('"number"')}`, + ), + backgroundChart: BackgroundChartSchema.optional().describe( + 'Background trend sparkline drawn behind the number. ' + + promqlOnlyFor('"number"'), + ), + limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Maximum number of slices or bars, one per series. Omit for the ' + + `default cap; 0 means unlimited. ${promqlOnlyFor('"pie" or "bar"')}`, + ), + heatmapMode: PromqlHeatmapModeSchema.optional().describe( + '"distribution" (the default) buckets sample values, "series" shows ' + + 'one row per series, "histogram" plots Prometheus histogram ' + + `buckets (_bucket series by le). ${promqlOnlyFor('"heatmap"')}`, + ), + heatmapScaleType: HeatmapScaleTypeSchema.optional().describe( + `Scale of the y axis. ${promqlOnlyFor('"heatmap"')}`, + ), + }) + .describe( + 'PromQL tile query and display settings. PromQL tiles cannot have alerts.', + ), +}); + const mcpTileSchema = z.union([ mcpLineTileSchema, mcpBarTileSchema, @@ -1380,7 +1532,11 @@ const mcpPatchTileSchema = z.union([ mcpPatchTileLayoutSchema.extend({ config: mcpSqlTileSchema.shape.config }), ]); -export const mcpTilesParam = z +const mcpPromqlPatchTileSchema = mcpPatchTileLayoutSchema.extend({ + config: mcpPromqlTileSchema.shape.config, +}); + +const mcpTilesParam = z .array(mcpTileSchema) .describe( 'Array of dashboard tiles. Each tile needs a name, optional layout (x/y/w/h), and a config block. ' + @@ -1402,6 +1558,20 @@ export const mcpTilesParam = z '"numberFormat": { "output": "duration", "factor": 0.000000001 } } }', ); +const PROMQL_TILE_EXAMPLE = + '\n6. PromQL line chart (promql sources only): { "name": "Request Rate", "config": { "configType": "promql", ' + + '"displayType": "line", "sourceId": "", ' + + '"expressions": [{ "expression": "sum by (job) (rate(http_requests_total{job=~\\"$job\\"}[$__rate_interval]))" }], ' + + '"legendTemplate": "{{job}}" } }'; + +/** PromQL tiles are offered only while PromQL is enabled. */ +export function buildMcpTilesParam(promqlEnabled: boolean) { + if (!promqlEnabled) return mcpTilesParam; + return z + .array(z.union([...mcpTileSchema.options, mcpPromqlTileSchema])) + .describe(`${mcpTilesParam.description}${PROMQL_TILE_EXAMPLE}`); +} + const VARIABLE_NAME_DESCRIPTION = "Token that tiles reference this filter's selected value by, eg. $variableName. " + 'A default is derived from the display name when omitted. ' + @@ -1590,6 +1760,32 @@ const mcpStaticListFilterSchema = z 'with the column passed explicitly, since the filter has no expression of its own.', ); +const mcpPromqlLabelFilterSchema = z + .object({ + ...mcpDashboardFilterBaseShape, + type: z + .literal(DashboardFilterType.enum.PROMETHEUS_LABEL) + .describe( + "Filter type discriminator for a filter whose dropdown lists a Prometheus label's values.", + ), + sourceId: objectIdSchema.describe( + 'ID of a source whose kind is "promql". Get IDs from clickstack_list_sources.', + ), + label: PromqlLabelDashboardFilterSchema.shape.label.describe( + 'Label whose values fill the dropdown, e.g. "job". ' + + 'clickstack_describe_source lists the label names of a promql source.', + ), + match: PromqlLabelDashboardFilterSchema.shape.match.describe( + 'Optional series selector narrowing which series the values come from, ' + + 'e.g. up{env="prod"}.', + ), + }) + .describe( + "A variable-only filter whose dropdown lists a Prometheus label's values. " + + 'PromQL tiles reference it in a label matcher: {job=~"$job"} for the ' + + 'default multi-select, or {job="$job"} with maxSelections: 1.', + ); + const mcpDashboardFilterSchema = z .discriminatedUnion('type', [ mcpQueryExpressionFilterSchema, @@ -1601,9 +1797,23 @@ const mcpDashboardFilterSchema = z 'STATIC_LIST declares them inline and is always variable-only.', ); -export type McpDashboardFilter = z.infer; +const mcpPromqlDashboardFilterSchema = z + .discriminatedUnion('type', [ + mcpQueryExpressionFilterSchema, + mcpStaticListFilterSchema, + mcpPromqlLabelFilterSchema, + ]) + .describe( + 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + + 'QUERY_EXPRESSION queries its dropdown values from a source column; ' + + 'STATIC_LIST declares them inline and is always variable-only; ' + + 'PROMETHEUS_LABEL lists the values of a label on a promql source and is ' + + 'always variable-only.', + ); + +export type McpDashboardFilter = z.infer; -export const mcpFiltersParam = z +const mcpFiltersParam = z .array(mcpDashboardFilterSchema) .describe( 'Optional dashboard-level filters. These define the dropdowns in the dashboard filter ' + @@ -1667,7 +1877,25 @@ export const mcpFiltersParam = z 'Endpoint dropdown, not by a tile, so the value is never applied twice.', ); -export const mcpPatchDashboardSchema = z.object({ +const PROMQL_FILTERS_EXAMPLE = + '\n\nA third type, PROMETHEUS_LABEL, lists the values of a label on a promql source ' + + 'and is always variable-only.\n\n' + + 'Example (PROMETHEUS_LABEL):\n' + + '[\n' + + ' { "type": "PROMETHEUS_LABEL", "name": "Job", "sourceId": "",\n' + + ' "label": "job", "variableName": "job" }\n' + + ']\n' + + 'A PromQL tile references it as rate(http_requests_total{job=~"$job"}[$__rate_interval]).'; + +/** PROMETHEUS_LABEL filters are offered only while PromQL is enabled. */ +export function buildMcpFiltersParam(promqlEnabled: boolean) { + if (!promqlEnabled) return mcpFiltersParam; + return z + .array(mcpPromqlDashboardFilterSchema) + .describe(`${mcpFiltersParam.description}${PROMQL_FILTERS_EXAMPLE}`); +} + +const mcpPatchDashboardSchema = z.object({ dashboardId: objectIdSchema.describe( 'ID of the dashboard to patch, from clickstack_get_dashboard (no id lists ' + 'dashboards) or clickstack_search_dashboards.', @@ -1697,6 +1925,17 @@ export const mcpPatchDashboardSchema = z.object({ ), }); +/** PromQL tiles are offered only while PromQL is enabled. */ +export function buildMcpPatchDashboardSchema(promqlEnabled: boolean) { + if (!promqlEnabled) return mcpPatchDashboardSchema; + return mcpPatchDashboardSchema.extend({ + tile: z + .union([...mcpPatchTileSchema.options, mcpPromqlPatchTileSchema]) + .optional() + .describe(mcpPatchDashboardSchema.shape.tile.description ?? ''), + }); +} + export const mcpSearchDashboardsSchema = z.object({ query: z .string() diff --git a/packages/api/src/mcp/tools/dashboards/validation.ts b/packages/api/src/mcp/tools/dashboards/validation.ts index b8d3274e72..e6298a026d 100644 --- a/packages/api/src/mcp/tools/dashboards/validation.ts +++ b/packages/api/src/mcp/tools/dashboards/validation.ts @@ -1,3 +1,4 @@ +import { getPromqlSeries } from '@hyperdx/common-utils/dist/core/promql'; import { TIME_SERIES_DISPLAY_TYPE_BY_NAME } from '@hyperdx/common-utils/dist/core/utils'; import type { FilterForVariableDeclaration } from '@hyperdx/common-utils/dist/filters'; import { @@ -7,6 +8,7 @@ import { } from '@hyperdx/common-utils/dist/filters'; import { isBuilderSavedChartConfig, + isPromqlSavedChartConfig, isRawSqlSavedChartConfig, } from '@hyperdx/common-utils/dist/guards'; import { @@ -294,6 +296,10 @@ export function getTileVariableWarnings( if (isRawSqlSavedChartConfig(config)) { validate(config.sqlTemplate, 'sql'); + } else if (isPromqlSavedChartConfig(config)) { + for (const { expression } of getPromqlSeries(config)) { + validate(expression, 'promql'); + } } else if ( isBuilderSavedChartConfig(config) && config.displayType === DisplayType.Markdown diff --git a/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts index f66ce964fc..dc8882183e 100644 --- a/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/v2/utils/__tests__/dashboards.test.ts @@ -461,7 +461,7 @@ describe('convertToExternalDashboard orphan-ref heal', () => { expect(ext.tiles[0].tabId).toBe('errors'); }); - it('returns PromQL tiles unless hidePromqlTiles is set', () => { + it('returns PromQL tiles and label filters unless hidePromql is set', () => { const doc = makeDoc({ tiles: [ makeTile({ @@ -476,16 +476,36 @@ describe('convertToExternalDashboard orphan-ref heal', () => { }), makeTile({ id: 'normal-tile' }), ], + filters: [ + { + id: 'label-filter', + type: 'PROMETHEUS_LABEL', + name: 'Job', + source: new mongoose.Types.ObjectId().toString(), + label: 'job', + isBroadcastEnabled: false, + isVariableEnabled: true, + }, + { + id: 'static-filter', + type: 'STATIC_LIST', + name: 'Env', + options: ['prod'], + isBroadcastEnabled: false, + isVariableEnabled: true, + }, + ], }); - expect(convertToExternalDashboard(doc).tiles.map(t => t.id)).toEqual([ - 'promql-tile', - 'normal-tile', + const shown = convertToExternalDashboard(doc); + expect(shown.tiles.map(t => t.id)).toEqual(['promql-tile', 'normal-tile']); + expect(shown.filters?.map(f => f.id)).toEqual([ + 'label-filter', + 'static-filter', ]); - expect( - convertToExternalDashboard(doc, { hidePromqlTiles: true }).tiles.map( - t => t.id, - ), - ).toEqual(['normal-tile']); + + const hidden = convertToExternalDashboard(doc, { hidePromql: true }); + expect(hidden.tiles.map(t => t.id)).toEqual(['normal-tile']); + expect(hidden.filters?.map(f => f.id)).toEqual(['static-filter']); }); // seriesLimit is three-state (matching the internal schema): omitted = diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index db481009ff..69e143e950 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -772,11 +772,11 @@ function convertTileToExternalChart( export function convertToExternalDashboard( dashboard: DashboardDocument, { - hidePromqlTiles = false, + hidePromql = false, }: { - // MCP cannot save PromQL tiles until HDX-5302, so its tools hide them - // rather than show tiles the agent cannot edit. Remove with that work. - hidePromqlTiles?: boolean; + // MCP hides PromQL tiles and PROMETHEUS_LABEL filters while PromQL is + // disabled on the deployment. + hidePromql?: boolean; } = {}, ): ExternalDashboard { const containers = dashboard.containers ?? []; @@ -793,16 +793,14 @@ export function convertToExternalDashboard( id: dashboardId, name: dashboard.name, tiles: dashboard.tiles - .filter( - tile => !(hidePromqlTiles && isPromqlSavedChartConfig(tile.config)), - ) + .filter(tile => !(hidePromql && isPromqlSavedChartConfig(tile.config))) .map(tile => convertTileToExternalChart(tile, containerById, dashboardId), ), tags: dashboard.tags || [], - filters: getExternalCompatibleFilters(dashboard.filters).map( - translateFilterToExternalFilter, - ), + filters: getExternalCompatibleFilters(dashboard.filters) + .filter(filter => !(hidePromql && isPrometheusLabelFilter(filter))) + .map(translateFilterToExternalFilter), savedQuery: dashboard.savedQuery ?? null, savedQueryLanguage: dashboard.savedQueryLanguage ?? null, savedFilterValues: getExternalCompatibleFilterValues(