Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/adhoc-filters-mcp.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/promqlTiles.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<ReturnType<typeof callTool>>) =>
JSON.parse(getFirstText(result));

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof callTool>>) =>
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('=|'),
]);
});
});
});
20 changes: 18 additions & 2 deletions packages/api/src/mcp/prompts/dashboards/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["<log, trace, or session source id>"], 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: "<variableName>", 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.
Expand Down Expand Up @@ -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.`;
}

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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: "<trace-source-id>" }
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/mcp/prompts/dashboards/promqlContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<promql source id>", "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": ["<promql source id>"] }. 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).`;
1 change: 0 additions & 1 deletion packages/api/src/mcp/tools/dashboards/getDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ export function registerGetDashboard({
}
const externalDashboard = convertToExternalDashboard(dashboard, {
hidePromql: !isPromqlEnabled(context),
hideAdhoc: true,
});
return {
content: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 2 additions & 7 deletions packages/api/src/mcp/tools/dashboards/patchDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(', ')}`,
Expand Down Expand Up @@ -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}` }
Expand Down
1 change: 0 additions & 1 deletion packages/api/src/mcp/tools/dashboards/queryTile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/api/src/mcp/tools/dashboards/queryTiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ export function registerQueryTiles({

const externalDashboard = convertToExternalDashboard(dashboard, {
hidePromql: !isPromqlEnabled(context),
hideAdhoc: true,
});
const allTiles = externalDashboard.tiles;

Expand Down
Loading
Loading