From 4d353be3e53af3ed0a6dc745b1d174bc12b820cd Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:36:25 -0500 Subject: [PATCH] feat(mcp): support ad hoc dashboard filters in the MCP dashboard tools Save, get, and patch now handle ADHOC filters and their adhoc saved conditions. PromQL ad hoc filters are offered only while PromQL is enabled; otherwise reads hide them and updates keep them, as for PROMETHEUS_LABEL filters. Saved conditions naming no ad hoc filter, or using an operator their filter can't apply, warn. Drops the temporary hideAdhoc option. --- .changeset/adhoc-filters-mcp.md | 12 ++ .../dashboards/promqlTiles.int.test.ts | 125 +++++++++++++++ .../dashboards/saveDashboard.int.test.ts | 79 ++++++++++ .../api/src/mcp/prompts/dashboards/content.ts | 20 ++- .../mcp/prompts/dashboards/promqlContent.ts | 1 + .../src/mcp/tools/dashboards/getDashboard.ts | 1 - .../mcp/tools/dashboards/getDashboardTile.ts | 1 - .../mcp/tools/dashboards/patchDashboard.ts | 9 +- .../api/src/mcp/tools/dashboards/queryTile.ts | 1 - .../src/mcp/tools/dashboards/queryTiles.ts | 1 - .../src/mcp/tools/dashboards/saveDashboard.ts | 13 +- .../api/src/mcp/tools/dashboards/schemas.ts | 149 +++++++++++++++++- .../src/mcp/tools/dashboards/validation.ts | 50 +++++- .../v2/utils/__tests__/dashboards.test.ts | 36 ++--- .../external-api/v2/utils/dashboards.ts | 29 ++-- packages/api/src/utils/externalApi.ts | 16 -- packages/api/src/utils/zod.ts | 5 +- 17 files changed, 461 insertions(+), 87 deletions(-) create mode 100644 .changeset/adhoc-filters-mcp.md diff --git a/.changeset/adhoc-filters-mcp.md b/.changeset/adhoc-filters-mcp.md new file mode 100644 index 0000000000..ef20b14cb6 --- /dev/null +++ b/.changeset/adhoc-filters-mcp.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/api': minor +--- + +feat(mcp): Create, read, and update ad hoc dashboard filters through the MCP dashboard tools + +`clickstack_save_dashboard`, `clickstack_get_dashboard`, and +`clickstack_patch_dashboard` now handle `ADHOC` filters and their `adhoc` +saved conditions. PromQL ad hoc filters are offered only while PromQL is +enabled, and are hidden and kept on update otherwise. Saved conditions that +name no ad hoc filter, or use an operator their filter can't apply, produce a +warning. 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 55f7c7e5ad..66ef31bb2a 100644 --- a/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts @@ -63,6 +63,19 @@ describe('MCP Dashboard Tools - PromQL tiles and label filters', () => { isVariableEnabled: true as const, }); + const storedAdhocFilter = (sourceType: 'sql' | 'promql') => ({ + id: `${sourceType}-adhoc`, + type: 'ADHOC' as const, + name: `${sourceType} conditions`, + sourceType, + sources: [ + sourceType === 'promql' ? promqlSourceId : ctx.traceSource._id.toString(), + ], + isBroadcastEnabled: true as const, + isVariableEnabled: true as const, + variableName: `${sourceType}_conditions`, + }); + const createStoredDashboard = () => new Dashboard({ name: 'PromQL dashboard', @@ -71,6 +84,14 @@ describe('MCP Dashboard Tools - PromQL tiles and label filters', () => { team: ctx.team._id, }).save(); + const createStoredAdhocDashboard = () => + new Dashboard({ + name: 'Ad hoc dashboard', + tiles: [], + filters: [storedAdhocFilter('sql'), storedAdhocFilter('promql')], + team: ctx.team._id, + }).save(); + const parseOutput = (result: Awaited>) => JSON.parse(getFirstText(result)); @@ -242,6 +263,63 @@ describe('MCP Dashboard Tools - PromQL tiles and label filters', () => { expect(parseOutput(saved).filters).toEqual(filters); }); + it('round-trips a PromQL ad hoc filter', async () => { + const { sources, sourceType, name, variableName } = + storedAdhocFilter('promql'); + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Ad hoc', + tiles: [], + filters: [{ type: 'ADHOC', name, sourceType, sources, variableName }], + savedFilterValues: [ + { + type: 'adhoc', + name: variableName, + conditions: [{ key: 'job', operator: '=|', value: 'api' }], + }, + ], + }); + expect(created.isError).toBeFalsy(); + const { id, filters, warnings } = parseOutput(created); + expect(warnings ?? []).toEqual([]); + expect(filters).toEqual([ + { ...storedAdhocFilter('promql'), id: expect.any(String) }, + ]); + + const saved = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id, + name: 'Ad hoc', + tiles: [], + filters, + }); + expect(saved.isError).toBeFalsy(); + expect(parseOutput(saved).filters).toEqual(filters); + }); + + it.each([ + ['a sql filter over a promql source', 'sql', () => promqlSourceId], + [ + 'a promql filter over a trace source', + 'promql', + () => ctx.traceSource._id.toString(), + ], + ] as const)('rejects %s', async (_label, sourceType, sourceId) => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Wrong kind', + tiles: [], + filters: [ + { + type: 'ADHOC', + name: 'Conditions', + sourceType, + sources: [sourceId()], + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain(sourceId()); + }); + 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', @@ -415,6 +493,53 @@ describe('MCP Dashboard Tools - PromQL tiles and label filters', () => { expect(stored?.tiles).toHaveLength(2); }); + it('hides PromQL ad hoc filters from clickstack_get_dashboard', async () => { + const dashboard = await createStoredAdhocDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: dashboard._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + expect(parseOutput(result).filters).toEqual([storedAdhocFilter('sql')]); + }); + + it('keeps hidden PromQL ad hoc filters when clickstack_save_dashboard updates the dashboard', async () => { + const dashboard = await createStoredAdhocDashboard(); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id: dashboard._id.toString(), + name: 'Ad hoc dashboard', + tiles: [], + filters: [{ ...storedAdhocFilter('sql'), name: 'Renamed' }], + }); + + expect(result.isError).toBeFalsy(); + const stored = await Dashboard.findById(dashboard._id).lean(); + expect(stored?.filters).toEqual([ + { ...storedAdhocFilter('sql'), name: 'Renamed' }, + storedAdhocFilter('promql'), + ]); + }); + + it('rejects a PromQL ad hoc filter', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'PromQL off', + tiles: [], + filters: [ + { + type: 'ADHOC', + name: 'Labels', + sourceType: 'promql', + sources: [promqlSourceId], + }, + ], + }); + + expect(result.isError).toBe(true); + expect(await Dashboard.countDocuments({ team: ctx.team._id })).toBe(0); + }); + it('rejects a PromQL tile', async () => { const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { name: 'PromQL off', diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts index 4940948fd0..296e31bf72 100644 --- a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts @@ -4027,4 +4027,83 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { expect(getFirstText(patchResult)).toContain('savedFilterValues'); }); }); + + describe('ad hoc filters', () => { + const adhocFilter = () => ({ + type: 'ADHOC' as const, + name: 'Conditions', + sourceType: 'sql' as const, + sources: [ctx.traceSource._id.toString()], + variableName: 'conditions', + }); + const adhocValue = { + type: 'adhoc' as const, + name: 'conditions', + conditions: [ + { key: 'ServiceName', operator: 'IN' as const, value: 'api' }, + { key: 'ServiceName', operator: 'IN' as const, value: 'web' }, + ], + }; + const parse = (result: Awaited>) => + JSON.parse(getFirstText(result)); + + it('round-trips an ad hoc filter and its saved conditions through save, get and save', async () => { + const created = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Ad hoc', + tiles: [], + filters: [adhocFilter()], + savedFilterValues: [adhocValue], + }); + expect(created.isError).toBeFalsy(); + const { id, warnings } = parse(created); + expect(warnings ?? []).toEqual([]); + + const read = parse( + await callTool(ctx.client!, 'clickstack_get_dashboard', { id }), + ); + expect(read.filters).toEqual([ + { + ...adhocFilter(), + id: expect.any(String), + isBroadcastEnabled: true, + isVariableEnabled: true, + }, + ]); + expect(read.savedFilterValues).toEqual([adhocValue]); + + const storedBefore = await Dashboard.findById(id).lean(); + const saved = await callTool(ctx.client!, 'clickstack_save_dashboard', { + id, + name: 'Ad hoc', + tiles: [], + filters: read.filters, + savedFilterValues: read.savedFilterValues, + }); + expect(saved.isError).toBeFalsy(); + const storedAfter = await Dashboard.findById(id).lean(); + expect(storedAfter?.filters).toEqual(storedBefore?.filters); + expect(storedAfter?.savedFilterValues).toEqual([adhocValue]); + }); + + it('warns about saved conditions that no ad hoc filter, or not its source type, can apply', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad defaults', + tiles: [], + filters: [adhocFilter()], + savedFilterValues: [ + { ...adhocValue, name: 'conditons' }, + { + ...adhocValue, + conditions: [{ key: 'job', operator: '=|', value: 'api' }], + }, + ], + }); + + expect(result.isError).toBeFalsy(); + expect(parse(result).warnings).toEqual([ + expect.stringContaining('"conditons"'), + expect.stringContaining('=|'), + ]); + }); + }); }); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 84c8199bb3..453be2e2e6 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -135,6 +135,8 @@ Apply these before calling clickstack_save_dashboard. Each rule is enforced by t 9g. SET savedFilterValues TO GIVE THE DASHBOARD A DEFAULT SELECTION. Pass savedFilterValues on clickstack_save_dashboard or clickstack_patch_dashboard to pre-select filter values whenever the dashboard opens. Pair this with a required filter (9e) so the dashboard opens scoped rather than blocked. +9h. USE AN ADHOC FILTER TO LET THE USER BUILD FREE-FORM CONDITIONS. filters: [{ type: "ADHOC", name, sourceType: "sql", sources: [""], variableName? }] adds an input where the user picks any column or map key, an operator, and a value; clicking a value in a tile adds a condition too. Every condition is ANDed onto the tiles of its sources (or of appliesToSourceIds) with no per-tile wiring. It is never a variable: its variableName only names the entry its conditions are saved under. Seed conditions with savedFilterValues: [{ type: "adhoc", name: "", conditions: [{ key: "ServiceName", operator: "IN", value: "api" }] }]; IN conditions on the same key are ORed. Declare at most one ad hoc filter per source, since a click updates every ad hoc filter on the clicked tile's source. + 10. UPDATE IS REPLACE, NOT MERGE. clickstack_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call clickstack_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. 11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not. @@ -196,7 +198,8 @@ Dashboards open with a 15-minute default window. There is no dashboard-level fie - Using $__filter or $__conditionalAll in a Lucene where (they are matched as literal text; switch to whereLanguage: "sql", or write ServiceName:$var). - Chaining a filter's where off a filter that is not variable-enabled. Only isVariableEnabled: true publishes the $variableName token; without it the reference resolves to nothing and the downstream dropdown is unscoped or empty. - Referencing a filter's OWN variable in its where. That narrows its dropdown to the values already picked, so the rest of the options vanish from a multi-select. Reference a sibling filter's variable instead. -- Mixing filter-type fields: expression/sourceId/where belong to QUERY_EXPRESSION filters, options to STATIC_LIST filters. +- Mixing filter-type fields: expression/sourceId/where belong to QUERY_EXPRESSION filters, options to STATIC_LIST filters, sourceType/sources to ADHOC filters. +- Referencing an ADHOC filter's variableName in a tile. Ad hoc conditions reach tiles only by broadcast; the name is not a variable. - Expecting a STATIC_LIST filter to broadcast. It cannot; a tile must reference its $variableName (via $__filter with the column passed explicitly) or nothing changes when the user picks a value.`; } @@ -1168,7 +1171,7 @@ Rules: Optional dashboard-level filter declarations. Each entry adds a dropdown to the dashboard header. Use this for focused per-dimension dashboards (per-service, per-tenant, per-endpoint) instead of hardcoding the dimension into every tile's where clause. -Two filter types: QUERY_EXPRESSION queries its dropdown values from a source column, while STATIC_LIST declares them inline as a hand-authored options list and is always variable-only (see the static filter shape below). +QUERY_EXPRESSION queries its dropdown values from a source column, while STATIC_LIST declares them inline as a hand-authored options list and is always variable-only (see the static filter shape below). ADHOC holds free-form conditions the user builds, and always broadcasts them (see the ad hoc filter shape below). A QUERY_EXPRESSION filter does one of two things with the value the user picks, or both: @@ -1207,6 +1210,19 @@ Example (static list): { type: "STATIC_LIST", name: "Environment", options: ["prod", "staging", "dev"], variableName: "env" } ] +Ad hoc filter shape: + { type: "ADHOC", name, sourceType, sources, where?, whereLanguage?, appliesToSourceIds?, variableName? } + + sourceType "sql": keys are the columns and map keys of Log, Trace, or Session sources. + sources IDs of the sources whose keys and values the input suggests, all of sourceType. + where Optional condition narrowing the rows keys and values are suggested from. sql only, never applied to a tile. + appliesToSourceIds Optional IDs of the sources whose tiles the conditions apply to. Omit to use the filter's sources. + variableName The name its conditions are saved under in savedFilterValues. NOT a variable: tiles cannot reference it. + + No mode flags or selection limits: an ad hoc filter always broadcasts and is never a variable. The user adds conditions in the filter bar or by clicking values in tiles; seed them with an "adhoc" savedFilterValues entry: + savedFilterValues: [{ type: "adhoc", name: "conditions", conditions: [{ key: "SeverityText", operator: "=", value: "error" }] }] + Operators: =, !=, =~ (regex), !~, LIKE, NOT LIKE, and IN. Conditions are ANDed, except that IN conditions on the same key are ORed into one IN (...). + Example (broadcast to every tile, the common case): filters: [ { type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", sourceId: "" } diff --git a/packages/api/src/mcp/prompts/dashboards/promqlContent.ts b/packages/api/src/mcp/prompts/dashboards/promqlContent.ts index 88d4cb813b..b9446d82f7 100644 --- a/packages/api/src/mcp/prompts/dashboards/promqlContent.ts +++ b/packages/api/src/mcp/prompts/dashboards/promqlContent.ts @@ -49,5 +49,6 @@ Sources with kind "promql" hold Prometheus metrics. Chart them with PROMQL tiles - displayType works as for builder tiles. line, stacked_bar and stacked_line plot every expression; number, table, pie and bar query only the first and can set queryType "instant" to evaluate once at the end of the range. - legendTemplate names each series from its labels, e.g. "{{job}} / {{instance}}". - Filter with a PROMETHEUS_LABEL dashboard filter: { "type": "PROMETHEUS_LABEL", "name": "Job", "sourceId": "", "label": "job", "variableName": "job" }. Tiles reference it in a matcher: {job=~"$job"} (multi-select) or {job="$job"} with maxSelections: 1. clickstack_describe_source lists the label names. + - For free-form label conditions, use an ADHOC filter with sourceType "promql": { "type": "ADHOC", "name": "Labels", "sourceType": "promql", "sources": [""] }. Its conditions are added as label matchers to every selector in the PromQL tiles on those sources, with no tile wiring. Operators: =, !=, =~, !~, and =|; =| conditions on the same label are ORed into one =~"a|b" matcher. Seed them via savedFilterValues as for sql ad hoc filters. - Draft each expression with clickstack_promql first, then validate the saved tile with clickstack_query_tile. PromQL tiles cannot have alerts. - Fetch the query_guide prompt for the PromQL syntax notes (rate(), histogram_quantile, the $__rate_interval macro).`; diff --git a/packages/api/src/mcp/tools/dashboards/getDashboard.ts b/packages/api/src/mcp/tools/dashboards/getDashboard.ts index 2133ec09b3..a18f09952b 100644 --- a/packages/api/src/mcp/tools/dashboards/getDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/getDashboard.ts @@ -63,7 +63,6 @@ export function registerGetDashboard({ } const externalDashboard = convertToExternalDashboard(dashboard, { hidePromql: !isPromqlEnabled(context), - hideAdhoc: true, }); return { content: [ diff --git a/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts index 7d331d965c..f9a4d2632f 100644 --- a/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts +++ b/packages/api/src/mcp/tools/dashboards/getDashboardTile.ts @@ -51,7 +51,6 @@ export function registerGetDashboardTile({ const externalDashboard = convertToExternalDashboard(dashboard, { hidePromql: !isPromqlEnabled(context), - hideAdhoc: true, }); 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 4488ca1c7b..36d5f43a21 100644 --- a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts @@ -15,7 +15,6 @@ import { isConfigTile, validateDashboardTiles, } from '@/routers/external-api/v2/utils/dashboards'; -import { getMcpCompatibleFilterValues } from '@/utils/externalApi'; import type { ExternalDashboardTileWithId } from '@/utils/zod'; import { externalDashboardTileSchemaWithId } from '@/utils/zod'; @@ -127,7 +126,7 @@ export function registerPatchDashboard({ // Convert only for the error message (read-only, no write-back). const externalDashboard = convertToExternalDashboard( existingDashboard, - { hidePromql: !promqlEnabled, hideAdhoc: true }, + { hidePromql: !promqlEnabled }, ); return mcpUserError( `Tile not found: ${tileId}. Available tile IDs: ${externalDashboard.tiles.map(t => `${t.id} (${t.name})`).join(', ')}`, @@ -270,11 +269,7 @@ export function registerPatchDashboard({ name: updatedDashboard.name, tags: updatedDashboard.tags, ...(savedFilterValues !== undefined - ? { - savedFilterValues: getMcpCompatibleFilterValues( - updatedDashboard.savedFilterValues, - ), - } + ? { savedFilterValues: updatedDashboard.savedFilterValues ?? [] } : {}), ...(frontendUrl ? { url: `${frontendUrl}/dashboards/${updatedDashboard._id}` } diff --git a/packages/api/src/mcp/tools/dashboards/queryTile.ts b/packages/api/src/mcp/tools/dashboards/queryTile.ts index 0f9df6b116..b6cd385b57 100644 --- a/packages/api/src/mcp/tools/dashboards/queryTile.ts +++ b/packages/api/src/mcp/tools/dashboards/queryTile.ts @@ -78,7 +78,6 @@ export function registerQueryTile({ const externalDashboard = convertToExternalDashboard(dashboard, { hidePromql: !isPromqlEnabled(context), - hideAdhoc: true, }); 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 9860ad768c..44af35c950 100644 --- a/packages/api/src/mcp/tools/dashboards/queryTiles.ts +++ b/packages/api/src/mcp/tools/dashboards/queryTiles.ts @@ -184,7 +184,6 @@ export function registerQueryTiles({ const externalDashboard = convertToExternalDashboard(dashboard, { hidePromql: !isPromqlEnabled(context), - hideAdhoc: true, }); 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 bde01373e8..fc5da0d8ec 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -1,7 +1,6 @@ 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'; @@ -22,6 +21,7 @@ import { convertExternalTilesToInternal, convertToExternalDashboard, createDashboardBodySchema, + isPromqlFilter, resolveSavedQueryLanguage, updateDashboardBodySchema, validateDashboardTiles, @@ -236,7 +236,6 @@ async function createDashboard({ const externalDashboard = convertToExternalDashboard(newDashboard, { hidePromql: !promqlEnabled, - hideAdhoc: true, }); return { content: [ @@ -358,9 +357,9 @@ async function updateDashboard({ tilesWithId, existingTileIds, ); - // 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 + // With PromQL disabled, reads hide PromQL tiles and PromQL 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 = promqlEnabled @@ -393,8 +392,7 @@ async function updateDashboard({ const hiddenFilters = promqlEnabled ? [] : (existingDashboard.filters ?? []).filter( - filter => - isPrometheusLabelFilter(filter) && !requestFilterIds.has(filter.id), + filter => isPromqlFilter(filter) && !requestFilterIds.has(filter.id), ); // The body schema checks that variable names are unique only across the @@ -453,7 +451,6 @@ async function updateDashboard({ const externalDashboard = convertToExternalDashboard(updatedDashboard, { hidePromql: !promqlEnabled, - hideAdhoc: true, }); return { content: [ diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index f9e40efe58..855f782373 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -3,6 +3,9 @@ // separator (e.g. "Source ID – call clickstack_list_sources") for LLM // readability. Reformatting all separators is out of scope here. import { + ADHOC_FILTER_MAX_CONDITIONS, + AdhocFilterConditionSchema, + AdhocFilterSourceTypeSchema, AggregateFunctionSchema, BackgroundChartSchema, ChartPaletteTokenSchema, @@ -34,6 +37,7 @@ import { } from '@/mcp/tools/query/schemas'; import { QUERYABLE_METRIC_KINDS } from '@/mcp/tools/sources/metricKinds'; import { + externalAdhocSavedFilterValueSchema, externalSqlSavedFilterValueSchema, externalVariableSavedFilterValueSchema, objectIdSchema, @@ -1791,15 +1795,91 @@ const mcpPromqlLabelFilterSchema = z 'default multi-select, or {job="$job"} with maxSelections: 1.', ); +// Selection limits and requirements have no meaning for a list of conditions, +// so the ad hoc shape doesn't carry them. +const mcpAdhocFilterSchema = z + .object({ + id: mcpDashboardFilterBaseShape.id, + name: mcpDashboardFilterBaseShape.name, + variableName: variableNameSchema.describe( + "Unique name the filter's conditions are saved under in savedFilterValues. " + + 'Tiles CANNOT reference it as a variable. A default is derived from the ' + + "display name when omitted. Must be unique across the dashboard's " + + 'variable-enabled filters.', + ), + type: z + .literal(DashboardFilterType.enum.ADHOC) + .describe( + 'Filter type discriminator for a filter holding free-form key / operator / value conditions.', + ), + sourceType: z + .literal('sql') + .describe( + '"sql": keys are the columns and map keys of Log, Trace, or Session sources.', + ), + sources: z + .array(objectIdSchema) + .min(1) + .describe( + 'IDs of the Log, Trace, or Session sources (from clickstack_list_sources) ' + + 'whose keys and values the filter suggests.', + ), + where: z + .string() + .optional() + .describe( + 'Optional condition narrowing the rows keys and values are suggested from. ' + + "Never applied to tiles. May reference other filters' variables.", + ), + whereLanguage: SearchConditionTrimmedLanguageSchema.describe( + 'Query language of `where`.', + ), + appliesToSourceIds: z + .array(objectIdSchema) + .optional() + .describe( + 'IDs of the sources whose tiles the conditions apply to, of the same kind ' + + "as `sources`. Omit to apply to the tiles of the filter's `sources`.", + ), + }) + .describe( + 'An ad hoc filter: the user picks any number of key / operator / value ' + + 'conditions in the filter bar, or clicks values in tiles, and every ' + + 'condition is ANDed onto the tiles the filter applies to. Always ' + + 'broadcasts and is never a variable. Seed its conditions with an ' + + '"adhoc" entry in savedFilterValues.', + ); + +const mcpPromqlAdhocFilterSchema = mcpAdhocFilterSchema.extend({ + sourceType: AdhocFilterSourceTypeSchema.describe( + '"sql": keys are the columns and map keys of Log, Trace, or Session sources. ' + + '"promql": keys are the labels of promql sources, and conditions are added ' + + 'as label matchers to every selector in PromQL tiles.', + ), + sources: z + .array(objectIdSchema) + .min(1) + .describe( + 'IDs of the sources (from clickstack_list_sources) whose keys and values ' + + 'the filter suggests: Log, Trace, or Session sources for sourceType ' + + '"sql", promql sources for "promql".', + ), + where: mcpAdhocFilterSchema.shape.where.describe( + `${mcpAdhocFilterSchema.shape.where.description} sourceType "sql" only.`, + ), +}); + const mcpDashboardFilterSchema = z .discriminatedUnion('type', [ mcpQueryExpressionFilterSchema, mcpStaticListFilterSchema, + mcpAdhocFilterSchema, ]) .describe( 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + - 'Two types: QUERY_EXPRESSION queries its dropdown values from a source column; ' + - 'STATIC_LIST declares them inline and is always variable-only.', + 'QUERY_EXPRESSION queries its dropdown values from a source column; ' + + 'STATIC_LIST declares them inline and is always variable-only; ' + + 'ADHOC holds free-form conditions and always broadcasts.', ); const mcpPromqlDashboardFilterSchema = z @@ -1807,13 +1887,15 @@ const mcpPromqlDashboardFilterSchema = z mcpQueryExpressionFilterSchema, mcpStaticListFilterSchema, mcpPromqlLabelFilterSchema, + mcpPromqlAdhocFilterSchema, ]) .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.', + 'always variable-only; ADHOC holds free-form conditions on SQL or promql ' + + 'sources and always broadcasts.', ); export type McpDashboardFilter = z.infer; @@ -1826,9 +1908,11 @@ const mcpFiltersParam = z 'If another tile\'s onClick targets THIS dashboard with `filters: [{ expression: "X", ... }]`, ' + 'this array MUST declare a filter whose `expression` is "X". Otherwise the value is ' + 'dropped on arrival and the destination opens unfiltered.\n\n' + - 'Two filter types: QUERY_EXPRESSION queries its dropdown values from a source column, ' + + 'QUERY_EXPRESSION queries its dropdown values from a source column, ' + 'while STATIC_LIST declares them inline via `options` and is always variable-only ' + - '(it has no expression to broadcast).\n\n' + + '(it has no expression to broadcast). ADHOC lets the user build any number of ' + + 'key / operator / value conditions, with keys and values suggested from its ' + + '`sources`, and always broadcasts them; it is never a variable.\n\n' + 'A QUERY_EXPRESSION filter runs in one of two modes (or both). BROADCAST (the default) applies the ' + 'selected value as a condition on matching tiles with no per-tile wiring. VARIABLE ' + '(isVariableEnabled) exposes the value to tile queries as $variableName, which the tile ' + @@ -1879,7 +1963,16 @@ const mcpFiltersParam = z ']\n' + "A filter's `where` scopes only the values ITS dropdown offers; it is not applied to " + 'any tile. That is why Service keeps broadcasting here: its variable is read by the ' + - 'Endpoint dropdown, not by a tile, so the value is never applied twice.', + 'Endpoint dropdown, not by a tile, so the value is never applied twice.\n\n' + + 'Example (ad hoc: free-form conditions on the log and trace tiles, seeded via ' + + 'savedFilterValues):\n' + + '[\n' + + ' { "type": "ADHOC", "name": "Conditions", "sourceType": "sql",\n' + + ' "sources": ["", ""], "variableName": "conditions" }\n' + + ']\n' + + 'with savedFilterValues: [{ "type": "adhoc", "name": "conditions", "conditions": ' + + '[{ "key": "ServiceName", "operator": "IN", "value": "api" }, ' + + '{ "key": "ServiceName", "operator": "IN", "value": "web" }] }].', ); const PROMQL_FILTERS_EXAMPLE = @@ -1890,7 +1983,10 @@ const PROMQL_FILTERS_EXAMPLE = ' { "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]).'; + 'A PromQL tile references it as rate(http_requests_total{job=~"$job"}[$__rate_interval]).\n\n' + + 'An ADHOC filter with sourceType "promql" reads label keys and values from promql ' + + '`sources` and adds its conditions as label matchers to every selector in the PromQL ' + + 'tiles it applies to; no tile wiring is needed.'; /** PROMETHEUS_LABEL filters are offered only while PromQL is enabled. */ export function buildMcpFiltersParam(promqlEnabled: boolean) { @@ -1930,9 +2026,46 @@ const mcpVariableSavedFilterValueSchema = ), }); +const adhocValueShape = externalAdhocSavedFilterValueSchema.shape; +const mcpAdhocSavedFilterValueSchema = + externalAdhocSavedFilterValueSchema.extend({ + type: adhocValueShape.type.describe( + 'Entry kind: the conditions of an ADHOC filter.', + ), + name: adhocValueShape.name.describe( + 'The variableName of the ADHOC filter these conditions belong to.', + ), + conditions: z + .array( + AdhocFilterConditionSchema.extend({ + key: AdhocFilterConditionSchema.shape.key.describe( + 'A SQL expression (column or map access, e.g. "ServiceName" or ' + + '"SpanAttributes[\'http.method\']") for a sql filter, or a label ' + + 'name for a promql filter.', + ), + operator: AdhocFilterConditionSchema.shape.operator.describe( + '=, !=, =~ (regex), !~ (not regex). sql filters also take LIKE, ' + + 'NOT LIKE, and IN; promql filters take =|. Conditions are ANDed, ' + + 'except that IN (sql) or =| (promql) conditions on the same key ' + + 'are ORed into one IN (...) or =~"a|b" condition, so each carries ' + + 'one value.', + ), + value: AdhocFilterConditionSchema.shape.value.describe( + 'The value, LIKE pattern, or regular expression to compare against.', + ), + }), + ) + .max(ADHOC_FILTER_MAX_CONDITIONS) + .describe("The filter's conditions."), + }); + export const mcpSavedFilterValuesParam = z .array( - z.union([mcpSqlSavedFilterValueSchema, mcpVariableSavedFilterValueSchema]), + z.union([ + mcpSqlSavedFilterValueSchema, + mcpVariableSavedFilterValueSchema, + mcpAdhocSavedFilterValueSchema, + ]), ) .describe( 'Default filter selections restored every time the dashboard is opened. ' + diff --git a/packages/api/src/mcp/tools/dashboards/validation.ts b/packages/api/src/mcp/tools/dashboards/validation.ts index 58d82da8de..93fa87f4b2 100644 --- a/packages/api/src/mcp/tools/dashboards/validation.ts +++ b/packages/api/src/mcp/tools/dashboards/validation.ts @@ -4,6 +4,7 @@ import type { FilterForVariableDeclaration } from '@hyperdx/common-utils/dist/fi import { getDashboardVariableDeclarations, getFilterVariableName, + isAdhocConditionSupported, isFilterVariableEnabled, } from '@hyperdx/common-utils/dist/filters'; import { @@ -18,6 +19,8 @@ import { TIME_RANGE_MACROS, } from '@hyperdx/common-utils/dist/macros'; import { + AdhocFilterSourceType, + AdhocFilterValue, DashboardFilterValue, DisplayType, QueryExpressionDashboardFilter, @@ -246,14 +249,17 @@ export function getFilterVariableWarnings( } /** - * Non-blocking check: a saved filter value of type 'variable' whose `name` - * matches no variable-enabled filter is silently ignored when the dashboard - * loads, so a typo'd variable name looks like a saved default but never - * applies. Warn so the agent can fix the name or add the filter. + * Non-blocking check: a saved filter value of type 'variable' or 'adhoc' whose + * `name` matches no filter of its kind, or an ad hoc condition its filter's + * source type can't express, is silently ignored when the dashboard loads, so + * it looks like a saved default but never applies. Warn so the agent can fix + * it. */ export function getSavedFilterValueWarnings( savedFilterValues: DashboardFilterValue[] | undefined, - filters: FilterForVariableDeclaration[] | undefined, + filters: + | (FilterForVariableDeclaration & { sourceType?: AdhocFilterSourceType })[] + | undefined, ): string[] { if (!savedFilterValues?.length) return []; const declaredNames = new Set( @@ -261,8 +267,19 @@ export function getSavedFilterValueWarnings( declaration => declaration.name, ), ); + const adhocSourceTypeByName = new Map( + (filters ?? []).flatMap(filter => + filter.type === 'ADHOC' && filter.sourceType + ? [[getFilterVariableName(filter), filter.sourceType] as const] + : [], + ), + ); const warnings: string[] = []; for (const value of savedFilterValues) { + if (value.type === 'adhoc') { + warnings.push(...getAdhocValueWarnings(value, adhocSourceTypeByName)); + continue; + } if (value.type !== 'variable') continue; const name = value.name.trim(); if (!name || declaredNames.has(name)) continue; @@ -276,6 +293,29 @@ export function getSavedFilterValueWarnings( return warnings; } +function getAdhocValueWarnings( + value: AdhocFilterValue, + sourceTypeByName: ReadonlyMap, +): string[] { + const sourceType = sourceTypeByName.get(value.name.trim()); + if (sourceType === undefined) { + return [ + `savedFilterValues has ad hoc conditions for "${value.name}", which no ADHOC ` + + "filter's variableName matches. They are ignored when the dashboard loads: " + + 'fix the name or remove the entry.', + ]; + } + const unsupported = value.conditions.filter( + condition => !isAdhocConditionSupported(sourceType, condition), + ); + if (unsupported.length === 0) return []; + return [ + `savedFilterValues has ad hoc conditions for "${value.name}" using ` + + `${[...new Set(unsupported.map(c => c.operator))].join(', ')}, which a ` + + `${sourceType} filter cannot apply. They are not applied to tiles.`, + ]; +} + function variableIssueSubject(language: TemplateLanguage): string { switch (language) { case 'sql': 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 dec4319981..9ab3802983 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 @@ -508,41 +508,35 @@ describe('convertToExternalDashboard orphan-ref heal', () => { expect(hidden.filters?.map(f => f.id)).toEqual(['static-filter']); }); - it('hides ad hoc filters and their saved conditions only when asked', () => { - const adhoc = { - id: 'adhoc-filter', + it('returns ad hoc filters and their saved conditions, hiding PromQL ones with PromQL', () => { + const adhoc = (sourceType: 'sql' | 'promql') => ({ + id: `${sourceType}-adhoc`, type: 'ADHOC' as const, - name: 'Conditions', - sourceType: 'sql' as const, - sources: [new mongoose.Types.ObjectId().toString()], + name: sourceType, + sourceType, + sources: ['65f5e4a3b9e77c001a111111'], isBroadcastEnabled: true as const, isVariableEnabled: true as const, - variableName: 'conds', - }; - const variableValue = { - type: 'variable' as const, - name: 'env', - values: ['prod'], - }; + }); const adhocValue = { type: 'adhoc' as const, - name: 'conds', + name: 'sql', conditions: [ { key: 'ServiceName', operator: '=' as const, value: 'api' }, ], }; const doc = makeDoc({ - filters: [adhoc], - savedFilterValues: [variableValue, adhocValue], + filters: [adhoc('sql'), adhoc('promql')], + savedFilterValues: [adhocValue], }); const shown = convertToExternalDashboard(doc); - expect(shown.filters).toEqual([adhoc]); - expect(shown.savedFilterValues).toEqual([variableValue, adhocValue]); + expect(shown.filters).toEqual([adhoc('sql'), adhoc('promql')]); + expect(shown.savedFilterValues).toEqual([adhocValue]); - const hidden = convertToExternalDashboard(doc, { hideAdhoc: true }); - expect(hidden.filters).toEqual([]); - expect(hidden.savedFilterValues).toEqual([variableValue]); + const hidden = convertToExternalDashboard(doc, { hidePromql: true }); + expect(hidden.filters).toEqual([adhoc('sql')]); + expect(hidden.savedFilterValues).toEqual([adhocValue]); }); // 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 7e84a6b664..a9f2c51592 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -32,6 +32,7 @@ import { DASHBOARD_MAX_CONTAINERS, DashboardContainer, DashboardContainerSchema, + DashboardFilter, DisplayType, HeatmapMode, isLogSource, @@ -57,8 +58,6 @@ import { getConnectionsByTeam } from '@/controllers/connection'; import { getSources } from '@/controllers/sources'; import Dashboard, { DashboardDocument } from '@/models/dashboard'; import { - getMcpCompatibleFilters, - getMcpCompatibleFilterValues, translateExternalChartToTileConfig, translateExternalFilterToFilter, translateFilterToExternalFilter, @@ -771,17 +770,22 @@ function convertTileToExternalChart( }; } +/** Whether a filter only works while PromQL is enabled. */ +export function isPromqlFilter(filter: DashboardFilter): boolean { + return ( + isPrometheusLabelFilter(filter) || + (isAdhocFilter(filter) && filter.sourceType === 'promql') + ); +} + export function convertToExternalDashboard( dashboard: DashboardDocument, { hidePromql = false, - hideAdhoc = false, }: { - // MCP hides PromQL tiles and PROMETHEUS_LABEL filters while PromQL is - // disabled on the deployment. + // MCP hides PromQL tiles and PromQL filters while PromQL is disabled on + // the deployment. hidePromql?: boolean; - // MCP doesn't expose ad hoc filters or their saved conditions yet. - hideAdhoc?: boolean; } = {}, ): ExternalDashboard { const containers = dashboard.containers ?? []; @@ -803,17 +807,12 @@ export function convertToExternalDashboard( convertTileToExternalChart(tile, containerById, dashboardId), ), tags: dashboard.tags || [], - filters: (hideAdhoc - ? getMcpCompatibleFilters(dashboard.filters) - : (dashboard.filters ?? []) - ) - .filter(filter => !(hidePromql && isPrometheusLabelFilter(filter))) + filters: (dashboard.filters ?? []) + .filter(filter => !(hidePromql && isPromqlFilter(filter))) .map(translateFilterToExternalFilter), savedQuery: dashboard.savedQuery ?? null, savedQueryLanguage: dashboard.savedQueryLanguage ?? null, - savedFilterValues: hideAdhoc - ? getMcpCompatibleFilterValues(dashboard.savedFilterValues) - : (dashboard.savedFilterValues ?? []), + savedFilterValues: dashboard.savedFilterValues ?? [], // Mongoose persists missing arrays as []. Only emit containers when // the user actually saved one or more, so dashboards without the // organization layer round-trip with the field absent. diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index b0e2ed35d7..24ee4028b6 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -1,7 +1,5 @@ import { TIME_SERIES_DISPLAY_TYPE_BY_NAME } from '@hyperdx/common-utils/dist/core/utils'; import { - isAdhocFilter, - isAdhocFilterValue, isFilterBroadcastEnabled, isFilterRequired, isFilterVariableEnabled, @@ -240,20 +238,6 @@ export function translateExternalChartToTileConfig( }; } -/** The filters MCP may read; it doesn't expose ad hoc filters yet. */ -export function getMcpCompatibleFilters( - filters: DashboardFilter[] | undefined, -): DashboardFilter[] { - return (filters ?? []).filter(filter => !isAdhocFilter(filter)); -} - -/** Saved filter values MCP may read; it doesn't expose ad hoc filters yet. */ -export function getMcpCompatibleFilterValues( - values: IDashboard['savedFilterValues'], -): NonNullable { - return (values ?? []).filter(value => !isAdhocFilterValue(value)); -} - export function translateFilterToExternalFilter( filter: DashboardFilter, ): ExternalDashboardFilterWithId { diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 5a27dccf2b..17d8ff177c 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -242,10 +242,13 @@ export const externalSqlSavedFilterValueSchema = z.object({ export const externalVariableSavedFilterValueSchema = VariableFilterValueSchema.strict(); +export const externalAdhocSavedFilterValueSchema = + AdhocFilterValueSchema.strict(); + export const externalDashboardSavedFilterValueSchema = z.union([ externalSqlSavedFilterValueSchema, externalVariableSavedFilterValueSchema, - AdhocFilterValueSchema.strict(), + externalAdhocSavedFilterValueSchema, ]); // ================================