From 9ecac6e18250e2356e2258e0140f5411c926dc73 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:02:48 +0000 Subject: [PATCH 1/3] feat(api): support number-tile color in the MCP dashboard tools Add `color` and `colorRules` to builder number tiles and `color` to raw SQL number tiles in the MCP save_dashboard / patch_dashboard input schema, so a connected MCP client can author number-tile colors the same way the external REST dashboards API does (#2428). Both fields reuse `ChartPaletteTokenSchema` and `NumberTileColorConditionSchema` from common-utils, so the MCP surface validates the same palette tokens and operator subset the editor emits. The MCP tools already convert tiles through the color-aware external helpers, so no conversion changes were needed. Relates to #1360. Co-Authored-By: Claude Opus 4.8 --- .changeset/mcp-number-tile-color.md | 5 + .../dashboards/patchDashboard.test.ts | 14 +- .../dashboards/saveDashboard.test.ts | 121 +++++++++++++++++- .../api/src/mcp/prompts/dashboards/content.ts | 27 ++++ .../api/src/mcp/tools/dashboards/schemas.ts | 33 +++++ 5 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-number-tile-color.md diff --git a/.changeset/mcp-number-tile-color.md b/.changeset/mcp-number-tile-color.md new file mode 100644 index 0000000000..c83f70fe00 --- /dev/null +++ b/.changeset/mcp-number-tile-color.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +Support number-tile color in the MCP dashboard tools. `save_dashboard` and `patch_dashboard` now accept a static `color` and conditional `colorRules` on builder number tiles, and a static `color` on raw SQL number tiles, matching the external REST dashboards API. diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts index 98d4927c3c..dae74ab500 100644 --- a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts @@ -115,7 +115,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { const created = JSON.parse(getFirstText(createResult)); const tileId = created.tiles[0].id; - // Patch without specifying layout — layout should be preserved + // Patch without specifying layout; layout should be preserved const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -464,7 +464,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { ); expect(patchResult.isError).toBeFalsy(); - // Read raw DB tiles again — tiles B and C should be byte-identical + // Read raw DB tiles again; tiles B and C should be byte-identical const afterPatch = await Dashboard.findById(dashboardId).lean(); const tilesAfterPatch = (afterPatch as any).tiles; @@ -498,7 +498,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { const created = JSON.parse(getFirstText(createResult)); const tileId = created.tiles[0].id; - // Patch config only — no name field at all + // Patch config only; no name field at all const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -566,7 +566,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { $pull: { tiles: { id: tileAId } }, }); - // Now try to patch tile A — it no longer exists in the array. + // Now try to patch tile A; it no longer exists in the array. const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -623,6 +623,8 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { displayType: 'number', sourceId, select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + color: 'chart-blue', + colorRules: [{ operator: 'gte', value: 500, color: 'chart-error' }], }, }, }); @@ -643,5 +645,9 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { expect(tile.name).toBe('Patched'); expect(tile.config.displayType).toBe('number'); expect(tile.config.select[0].aggFn).toBe('avg'); + expect(tile.config.color).toBe('chart-blue'); + expect(tile.config.colorRules).toEqual([ + { operator: 'gte', value: 500, color: 'chart-error' }, + ]); }); }); diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts index 36e359e0bf..7888bb414c 100644 --- a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts @@ -393,6 +393,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { connectionId, sqlTemplate: 'SELECT 0.99 AS value LIMIT 1', numberFormat: { output: 'percent' as const, mantissa: 2 }, + color: 'chart-success' as const, }; const saveResult = await callTool( @@ -438,7 +439,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { ); expect(fetchedSqlTile.config).toMatchObject(sqlConfig); - // Update a DIFFERENT tile, passing all fetched tiles back — the + // Update a DIFFERENT tile, passing all fetched tiles back; the // formatted SQL tile must survive the round-trip untouched. const updateResult = await callTool( ctx.client!, @@ -713,6 +714,117 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { expect(result.isError).toBe(true); }); + it('should round-trip number-tile color and colorRules through save and get', async () => { + const sourceId = ctx.traceSource._id.toString(); + // One rule per operator family the number-tile editor emits: + // gt/gte/lt/lte (number), between ([min, max]), eq/neq (number). + const numberConfig = { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + color: 'chart-blue', + colorRules: [ + { operator: 'lt', value: 10, color: 'chart-gray' }, + { operator: 'lte', value: 0, color: 'chart-success' }, + { operator: 'between', value: [50, 99], color: 'chart-cyan' }, + { operator: 'eq', value: 42, color: 'chart-pink', label: 'Answer' }, + { operator: 'neq', value: 0, color: 'chart-purple' }, + { operator: 'gt', value: 100, color: 'chart-warning' }, + { operator: 'gte', value: 500, color: 'chart-error' }, + ], + }; + + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Number Color', + tiles: [{ name: 'Number', config: numberConfig }], + }, + ); + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + expect(saved.tiles[0].config).toMatchObject(numberConfig); + + const getResult = await callTool(ctx.client!, 'clickstack_get_dashboard', { + id: saved.id, + }); + expect(getResult.isError).toBeFalsy(); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.tiles[0].config).toMatchObject(numberConfig); + }); + + it('should reject a number-tile color that is not a palette token', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad Color', + tiles: [ + { + name: 'Number', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + color: 'not-a-token', + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should reject a number-tile colorRule with an unsupported operator', async () => { + const sourceId = ctx.traceSource._id.toString(); + // `regex` is a ColorConditionSchema operator but not part of the + // narrower NumberTileColorConditionSchema the editor (and this tool) + // accept, so it must be rejected. + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad ColorRule', + tiles: [ + { + name: 'Number', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + colorRules: [{ operator: 'regex', value: '.*', color: 'chart-blue' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should keep color but drop colorRules on a raw SQL number tile', async () => { + const connectionId = ctx.connection._id.toString(); + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'SQL Number Color', + tiles: [ + { + name: 'SLO', + config: { + configType: 'sql', + displayType: 'number', + connectionId, + sqlTemplate: 'SELECT 0.99 AS value LIMIT 1', + color: 'chart-success', + colorRules: [{ operator: 'gte', value: 1, color: 'chart-error' }], + }, + }, + ], + }, + ); + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + expect(saved.tiles[0].config.color).toBe('chart-success'); + expect(saved.tiles[0].config.colorRules).toBeUndefined(); + }); + it('should preserve display fields on builder tiles through save, get, update, and re-get', async () => { const sourceId = ctx.traceSource._id.toString(); const numberFormat = { @@ -753,6 +865,11 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { sourceId, select: [{ aggFn: 'count' as const, alias: 'Requests' }], numberFormat, + color: 'chart-green' as const, + colorRules: [ + { operator: 'gte' as const, value: 100, color: 'chart-warning' as const }, + { operator: 'gte' as const, value: 500, color: 'chart-error' as const }, + ], }; const tiles = [ { name: 'Line', config: lineConfig }, @@ -793,7 +910,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { const fetched = JSON.parse(getFirstText(getResult)); assertTiles(fetched); - // Update passing all fetched tiles back verbatim — every display + // Update passing all fetched tiles back verbatim; every display // field must survive a second pass through the MCP input schema. const updateResult = await callTool( ctx.client!, diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index a79fde71ab..7050baec82 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -977,6 +977,33 @@ Common outputs: data_rate Bytes per second. throughput Count per second. +== NUMBER TILE COLOR == + +number tiles can color the value. Two fields on the tile config: + + color A palette token applied to the number (e.g. "chart-blue", "chart-green", "chart-success"). Use it for a fixed brand or status color. + + colorRules Conditional colors. An ordered array, evaluated with the LAST matching rule winning, falling back to color (then the default text color) when none match. Up to 10 rules. USE THIS for status dashboards where a value should turn yellow then red as it crosses thresholds. + +Each colorRules entry is { operator, value, color, label? }: + gt / gte / lt / lte value is a number. e.g. { operator: "gte", value: 500, color: "chart-error" } + between value is a [min, max] pair. e.g. { operator: "between", value: [100, 500], color: "chart-warning" } + eq / neq value is a number or string. + +Colors are palette tokens, not hex. Order rules from least to most severe so the most severe match wins. Example: a latency tile green by default, warning past 200ms, error past 500ms: + config: { + displayType: "number", + sourceId: "...", + select: [{ aggFn: "avg", valueExpression: "Duration", numberFormat: { output: "duration", factor: 0.000000001 } }], + color: "chart-green", + colorRules: [ + { operator: "gte", value: 200000000, color: "chart-warning", label: "Slow" }, + { operator: "gte", value: 500000000, color: "chart-error", label: "Critical" } + ] + } + +colorRules is for number builder tiles only. Raw SQL number tiles (configType: "sql") support color but not colorRules. + == asRatio == Set asRatio: true on line / stacked_bar / table tiles with exactly 2 select items diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 5ad793f61a..c12d485737 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -4,11 +4,13 @@ // readability. Reformatting all separators is out of scope here. import { AggregateFunctionSchema, + ChartPaletteTokenSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_CONTAINERS, DashboardContainerSchema, DashboardFilterType, MetricsDataType, + NumberTileColorConditionSchema, SearchConditionLanguageSchema, } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; @@ -28,6 +30,26 @@ const tileLevelNumberFormatDescription = 'Most useful: { output: "duration", factor: 0.000000001 } to auto-format nanosecond durations, ' + 'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.'; +const numberTileColorDescription = + 'Static color for the displayed number, as a palette token such as ' + + '"chart-blue", "chart-green", or "chart-success" (see the enum for the ' + + 'full set). Applied unless a colorRules entry matches the value.'; + +const numberTileColorRulesDescription = + 'Conditional colors for the number, evaluated in array order with the ' + + 'last matching rule winning; falls back to color (then the default text ' + + 'color) when none match. Up to 10 rules. Each rule is ' + + '{ operator, value, color, label? }: operator gt | gte | lt | lte with a ' + + 'number value, between with a [min, max] value, or eq | neq with a number ' + + 'or string value. color is a palette token. Example: ' + + '[{ operator: "gte", value: 500, color: "chart-error", label: "Critical" }].'; + +const rawSqlNumberTileColorDescription = + 'Static color for the displayed number, as a palette token such as ' + + '"chart-blue" or "chart-success". Valid only when displayType is ' + + '"number", ignored otherwise. Raw SQL number tiles do not support ' + + 'conditional colorRules.'; + const mcpNumberFormatSchema = z.object({ output: z .enum([ @@ -490,6 +512,14 @@ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({ numberFormat: mcpNumberFormatSchema .optional() .describe(tileLevelNumberFormatDescription), + color: ChartPaletteTokenSchema.optional().describe( + numberTileColorDescription, + ), + colorRules: z + .array(NumberTileColorConditionSchema) + .max(10) + .optional() + .describe(numberTileColorRulesDescription), }), }); @@ -661,6 +691,9 @@ const mcpSqlTileSchema = mcpTileLayoutSchema.extend({ 'Scale the y-axis to the data range instead of starting at zero. ' + 'Valid only when displayType is "line", ignored otherwise.', ), + color: ChartPaletteTokenSchema.optional().describe( + rawSqlNumberTileColorDescription, + ), onClick: mcpOnClickSchema.optional(), }), }); From c20262a7f2087a7628fcdff5559443aaaa40526e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:56:18 +0000 Subject: [PATCH 2/3] style(api): apply prettier formatting to number-color MCP tests Wrap multi-line tool-call args and colorRules literals to satisfy prettier/prettier in saveDashboard.test.ts. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../dashboards/saveDashboard.test.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts index 7888bb414c..b329eb6ed2 100644 --- a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts @@ -746,9 +746,13 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { const saved = JSON.parse(getFirstText(saveResult)); expect(saved.tiles[0].config).toMatchObject(numberConfig); - const getResult = await callTool(ctx.client!, 'clickstack_get_dashboard', { - id: saved.id, - }); + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: saved.id, + }, + ); expect(getResult.isError).toBeFalsy(); const fetched = JSON.parse(getFirstText(getResult)); expect(fetched.tiles[0].config).toMatchObject(numberConfig); @@ -788,7 +792,9 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { displayType: 'number', sourceId, select: [{ aggFn: 'count' }], - colorRules: [{ operator: 'regex', value: '.*', color: 'chart-blue' }], + colorRules: [ + { operator: 'regex', value: '.*', color: 'chart-blue' }, + ], }, }, ], @@ -813,7 +819,9 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { connectionId, sqlTemplate: 'SELECT 0.99 AS value LIMIT 1', color: 'chart-success', - colorRules: [{ operator: 'gte', value: 1, color: 'chart-error' }], + colorRules: [ + { operator: 'gte', value: 1, color: 'chart-error' }, + ], }, }, ], @@ -867,8 +875,16 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { numberFormat, color: 'chart-green' as const, colorRules: [ - { operator: 'gte' as const, value: 100, color: 'chart-warning' as const }, - { operator: 'gte' as const, value: 500, color: 'chart-error' as const }, + { + operator: 'gte' as const, + value: 100, + color: 'chart-warning' as const, + }, + { + operator: 'gte' as const, + value: 500, + color: 'chart-error' as const, + }, ], }; const tiles = [ From da7e5fcd1a8bd65194935af39868c2cd4a5cec03 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:58:53 +0000 Subject: [PATCH 3/3] docs(api): align eq/neq row in the MCP number-tile color prompt Pad the eq / neq operator row so the description column lines up with the gt/gte/lt/lte and between rows in the plain-text tool prompt. Co-Authored-By: Claude Opus 4.8 --- packages/api/src/mcp/prompts/dashboards/content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 7050baec82..b93b9ad6fa 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -988,7 +988,7 @@ number tiles can color the value. Two fields on the tile config: Each colorRules entry is { operator, value, color, label? }: gt / gte / lt / lte value is a number. e.g. { operator: "gte", value: 500, color: "chart-error" } between value is a [min, max] pair. e.g. { operator: "between", value: [100, 500], color: "chart-warning" } - eq / neq value is a number or string. + eq / neq value is a number or string. Colors are palette tokens, not hex. Order rules from least to most severe so the most severe match wins. Example: a latency tile green by default, warning past 200ms, error past 500ms: config: {