From 7d90304f0418555471347da76d83a5a109115cf4 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Tue, 3 Nov 2026 16:32:49 +0100 Subject: [PATCH 1/3] feat: Add the clickstack_promql MCP tool and PromQL prompt docs An agent could describe PromQL sources and save PromQL tiles, but had no way to run an expression without building a dashboard first. clickstack_promql runs one expression against a PromQL source over a time range, as a range query (time series rows plus seriesLabels) or an instant query (one row per series, a column per label). The tool wraps the expression in a one-tile config and runs it through runConfigTile, the path clickstack_query_tile uses, so results, macros, the auto step and errors match a saved tile with the same expression and granularity. The query_guide and create_dashboard prompts gain PromQL sections, the server instructions point PromQL sources at the tool, and the source hints lead with it. All of it is hidden while PromQL is disabled, so nothing new is visible there. --- .changeset/mcp-promql-query-tool.md | 5 + MCP.md | 38 +++ .../mcp/__tests__/dashboards/prompts.test.ts | 47 ++- .../src/mcp/__tests__/promqlGating.test.ts | 48 +++ .../mcp/__tests__/promqlQueryTool.int.test.ts | 321 ++++++++++++++++++ packages/api/src/mcp/mcpServer.ts | 14 +- .../api/src/mcp/prompts/dashboards/content.ts | 25 +- .../api/src/mcp/prompts/dashboards/index.ts | 8 +- .../mcp/prompts/dashboards/promqlContent.ts | 53 +++ packages/api/src/mcp/tools/query/index.ts | 6 + packages/api/src/mcp/tools/query/promql.ts | 114 +++++++ packages/api/src/mcp/utils/promql.ts | 10 +- 12 files changed, 674 insertions(+), 15 deletions(-) create mode 100644 .changeset/mcp-promql-query-tool.md create mode 100644 packages/api/src/mcp/__tests__/promqlGating.test.ts create mode 100644 packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts create mode 100644 packages/api/src/mcp/prompts/dashboards/promqlContent.ts create mode 100644 packages/api/src/mcp/tools/query/promql.ts diff --git a/.changeset/mcp-promql-query-tool.md b/.changeset/mcp-promql-query-tool.md new file mode 100644 index 0000000000..63e6087b72 --- /dev/null +++ b/.changeset/mcp-promql-query-tool.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +feat: MCP `clickstack_promql` tool runs PromQL against a PromQL source, and the MCP prompts and instructions cover PromQL sources, tiles and label filters while PromQL is enabled diff --git a/MCP.md b/MCP.md index 724e754630..c3693ed2d6 100644 --- a/MCP.md +++ b/MCP.md @@ -135,6 +135,7 @@ with: | `clickstack_event_deltas` | Compare two row groups and rank properties by how their value distributions differ | | `clickstack_emerging_signals` | Detect NEW or GONE log/event patterns between a baseline and a current window (two-window Drain novelty diff) | | `clickstack_sql` | Execute raw ClickHouse SQL for advanced queries (JOINs, CTEs, sub-queries); pass `queries` to run up to 10 in parallel | +| `clickstack_promql` | Run a PromQL expression (range or instant) against a PromQL source. Only registered when PromQL is enabled | | `clickstack_get_dashboard` | List all dashboards or get full detail for a specific dashboard | | `clickstack_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) | | `clickstack_delete_dashboard` | Permanently delete a dashboard and its attached alerts | @@ -197,3 +198,40 @@ Discovery workflow for metrics: 4. `clickstack_describe_metric` to drill into a specific metric and discover its attribute keys + sampled values before authoring queries. 5. `clickstack_timeseries` / `clickstack_table` to chart the metric. + +### PromQL Sources + +Available when PromQL is enabled for the deployment (self-hosted: the API's +`ENABLE_PROMQL` setting). With it off, PromQL sources, tiles, label filters, +the `clickstack_promql` tool and the PromQL prompt sections are all hidden +from MCP. + +A source with `kind: "promql"` holds Prometheus metrics, either on a +Prometheus-compatible endpoint or in a ClickHouse `TimeSeries` table. The +builder tools do not read it; use PromQL: + +1. `clickstack_list_sources` shows each PromQL source with its `backend` + (`prometheus` or `clickhouse`). +2. `clickstack_describe_source` lists its metric names and label names. +3. `clickstack_promql` runs an expression. A `range` query (default) returns + `{ __hdx_time_bucket, series_name, value }` rows plus `seriesLabels`; an + `instant` query evaluates at `endTime` and returns one row per series with + a column per label. `granularity` sets the step and `$__interval` in the + same `" "` format PromQL tiles use. +4. `clickstack_save_dashboard` with a `configType: "promql"` tile keeps the + chart; `clickstack_query_tile` returns the same rows for the same + expression and granularity. `PROMETHEUS_LABEL` filters give the dashboard + a label dropdown that tiles reference as `{job=~"$job"}`. + +```jsonc +{ + "sourceId": "", + "expression": "sum by (job) (rate(http_requests_total[$__rate_interval]))", + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-01T01:00:00Z" +} +``` + +The `$__interval`, `$__rate_interval` and `$__range` macros expand as they do +in a PromQL tile. Dashboard variables do not resolve in `clickstack_promql`; +write the value inline. diff --git a/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts b/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts index 00daf1c76b..f329672e8b 100644 --- a/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/prompts.test.ts @@ -278,10 +278,27 @@ describe('MCP Dashboard Prompts', () => { }); it('contains no em-dashes or en-dashes used as em-dashes', () => { - const prompt = buildQueryGuidePrompt(); + const prompt = buildQueryGuidePrompt({ promql: true }); expect(prompt).not.toMatch(/\u2014/); // [prose-lint: allow] expect(prompt).not.toMatch(/ \u2013 /); // [prose-lint: allow] }); + + it('documents PromQL sources only while PromQL is enabled', () => { + const withPromql = buildQueryGuidePrompt({ promql: true }); + expect(withPromql).toContain('== PROMQL SOURCES =='); + expect(withPromql).toMatch(/^Reference guide.*clickstack_promql/); + const idx = withPromql.indexOf('== PROMQL SOURCES =='); + const body = withPromql.slice(idx, withPromql.indexOf('\n== ', idx + 1)); + expect(body).toContain('clickstack_promql(sourceId, expression'); + expect(body).toContain('rate(http_requests_total[$__rate_interval])'); + expect(body).toContain('histogram_quantile(0.95, sum by (le)'); + expect(body).toContain('$__range'); + expect(body).toContain('queryType "instant"'); + + const withoutPromql = buildQueryGuidePrompt(); + expect(withoutPromql).not.toContain('PROMQL'); + expect(withoutPromql).not.toContain('clickstack_promql'); + }); }); describe('buildSourceSummary', () => { @@ -317,6 +334,26 @@ describe('MCP Dashboard Prompts', () => { }); describe('buildCreateDashboardPrompt', () => { + it('documents PromQL tiles only while PromQL is enabled', () => { + const args = [ + 'sources summary', + '000000000000000000000001', + '000000000000000000000002', + undefined, + ] as const; + const withPromql = buildCreateDashboardPrompt(...args, { promql: true }); + const idx = withPromql.indexOf('== PROMQL TILES =='); + expect(idx).toBeGreaterThan(withPromql.indexOf('== TILE TYPE GUIDE ==')); + expect(idx).toBeLessThan(withPromql.indexOf('== COLUMN NAMING ==')); + const body = withPromql.slice(idx, withPromql.indexOf('\n== ', idx + 1)); + expect(body).toContain('"configType": "promql"'); + expect(body).toContain('"type": "PROMETHEUS_LABEL"'); + expect(body).toContain('clickstack_promql'); + expect(body).toContain('clickstack_query_tile'); + + expect(buildCreateDashboardPrompt(...args)).not.toContain('PROMQL'); + }); + it('includes the design checklist and adapt-do-not-copy note', () => { const prompt = buildCreateDashboardPrompt( 'sources summary', @@ -452,7 +489,13 @@ describe('MCP Dashboard Prompts', () => { }); it('contains no em-dashes or en-dashes used as em-dashes', () => { - const prompt = buildCreateDashboardPrompt('sources summary', '', ''); + const prompt = buildCreateDashboardPrompt( + 'sources summary', + '', + '', + undefined, + { promql: true }, + ); expect(prompt).not.toMatch(/\u2014/); // [prose-lint: allow] // En-dash flanked by spaces reads as an em-dash substitute and is // disallowed by the voice rules. Numeric ranges (e.g. "1-20") are diff --git a/packages/api/src/mcp/__tests__/promqlGating.test.ts b/packages/api/src/mcp/__tests__/promqlGating.test.ts new file mode 100644 index 0000000000..39515e63c9 --- /dev/null +++ b/packages/api/src/mcp/__tests__/promqlGating.test.ts @@ -0,0 +1,48 @@ +import { createTestClient } from './mcpTestUtils'; + +/** + * With PromQL disabled, nothing in the MCP surface mentions PromQL: the + * clickstack_promql tool is not registered and the instructions and prompts + * leave it out. The tools/list, instructions and query_guide prompt are built + * from the registered definitions alone, so no database is needed here. + */ +describe('MCP PromQL gating', () => { + const connect = (promqlEnabled: boolean) => + createTestClient({ teamId: 'team-id', userId: 'user-id', promqlEnabled }); + + const queryGuide = async (client: Awaited>) => { + const { messages } = await client.getPrompt({ name: 'query_guide' }); + const content = messages[0].content; + return content.type === 'text' ? content.text : ''; + }; + + it('registers clickstack_promql and documents it while PromQL is enabled', async () => { + const client = await connect(true); + + const { tools } = await client.listTools(); + expect(tools.map(tool => tool.name)).toContain('clickstack_promql'); + expect(client.getInstructions()).toContain('clickstack_promql'); + expect(await queryGuide(client)).toContain('== PROMQL SOURCES =='); + + const { prompts } = await client.listPrompts(); + expect( + prompts.find(prompt => prompt.name === 'query_guide')?.description, + ).toContain('PromQL'); + }); + + it('hides clickstack_promql and every mention of it while PromQL is disabled', async () => { + const client = await connect(false); + + const { tools } = await client.listTools(); + expect(tools.map(tool => tool.name)).not.toContain('clickstack_promql'); + expect(client.getInstructions()).not.toContain('PromQL'); + const guide = await queryGuide(client); + expect(guide).not.toContain('== PROMQL SOURCES =='); + expect(guide).not.toContain('clickstack_promql'); + + const { prompts } = await client.listPrompts(); + expect( + prompts.find(prompt => prompt.name === 'query_guide')?.description, + ).not.toContain('PromQL'); + }); +}); diff --git a/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts new file mode 100644 index 0000000000..14158d2b28 --- /dev/null +++ b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts @@ -0,0 +1,321 @@ +import { Metadata } from '@hyperdx/common-utils/dist/core/metadata'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; + +import { + DEFAULT_DATABASE, + dropTimeSeriesTable, + seedTimeSeriesTagsTable, +} from '@/fixtures'; +import { setupDashboardTests } from '@/mcp/__tests__/dashboards/setup'; +import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils'; +import Connection from '@/models/connection'; +import { Source } from '@/models/source'; + +const mockFetch = jest.mocked(global.fetch); +// Set by jest.setup.ts before it stubs `fetch`. +const realFetch: typeof fetch = globalThis.realFetch; + +const TABLE = 'mcp_promql_query_tool'; +const START = 1700000000; +const startTime = new Date(START * 1000).toISOString(); +const endTime = new Date((START + 60) * 1000).toISOString(); + +const prometheusResponse = (data: unknown, init?: ResponseInit) => + new Response(JSON.stringify({ status: 'success', data }), init); + +describe('MCP clickstack_promql', () => { + const ctx = setupDashboardTests(); + + beforeAll(async () => { + await seedTimeSeriesTagsTable({ + table: TABLE, + series: ['checkout-0', 'checkout-1'].map(instance => ({ + metricName: 'mcp_up', + tags: { job: 'checkout', instance }, + startSec: START, + endSec: START + 60, + })), + withSamples: true, + }); + }); + + afterAll(async () => { + await dropTimeSeriesTable({ table: TABLE }); + }); + + beforeEach(() => ctx.reconnect({ promqlEnabled: true })); + + afterEach(() => { + jest.restoreAllMocks(); + mockFetch.mockReset(); + }); + + const createClickhouseSource = () => + Source.create({ + kind: SourceKind.Promql, + team: ctx.team._id, + connection: ctx.connection._id, + name: 'Metrics', + from: { databaseName: DEFAULT_DATABASE, tableName: TABLE }, + timestampValueExpression: 'timestamp', + }); + + const createPrometheusSource = async () => { + const connection = await Connection.create({ + team: ctx.team._id, + name: 'Prom', + host: 'http://user:secret@prom.example.com', + username: '', + password: '', + isPrometheusEndpoint: true, + }); + return Source.create({ + kind: SourceKind.Promql, + team: ctx.team._id, + connection: connection._id, + name: 'Prometheus', + from: { databaseName: 'prometheus', tableName: 'prometheus' }, + timestampValueExpression: 'timestamp', + }); + }; + + const promql = (args: Record) => + callTool(ctx.client!, 'clickstack_promql', { + expression: 'sum by (instance) (mcp_up)', + startTime, + endTime, + ...args, + }); + + const parseResult = (result: Awaited>) => + JSON.parse(getFirstText(result)).result; + + const requestedUrls = () => + mockFetch.mock.calls.map(([url]) => new URL(String(url))); + + it('returns time series rows and seriesLabels for a range query', async () => { + mockFetch.mockImplementation(realFetch); + const source = await createClickhouseSource(); + + const result = await promql({ + sourceId: source._id.toString(), + granularity: '1 minute', + }); + + expect(result.isError ? getFirstText(result) : '').toBe(''); + const { data, seriesLabels } = parseResult(result); + expect(seriesLabels).toEqual({ + '{instance="checkout-0"}': { instance: 'checkout-0' }, + '{instance="checkout-1"}': { instance: 'checkout-1' }, + }); + expect(data).toContainEqual({ + __hdx_time_bucket: '2023-11-14T22:14:00.000Z', + series_name: '{instance="checkout-0"}', + value: 1, + }); + const requested = requestedUrls().find(url => + url.pathname.endsWith('/query_range'), + ); + expect(requested?.searchParams.get('step')).toBe('60'); + }); + + it('picks the auto step when granularity is omitted', async () => { + mockFetch.mockImplementation(realFetch); + const source = await createClickhouseSource(); + + const result = await promql({ sourceId: source._id.toString() }); + + expect(result.isError).toBeFalsy(); + const requested = requestedUrls().find(url => + url.pathname.endsWith('/query_range'), + ); + expect(Number(requested?.searchParams.get('step'))).toBeGreaterThan(0); + }); + + it('returns one row per series with a column per label for an instant query', async () => { + mockFetch.mockImplementation(realFetch); + const source = await createClickhouseSource(); + + const result = await promql({ + sourceId: source._id.toString(), + expression: 'mcp_up', + queryType: 'instant', + }); + + expect(result.isError ? getFirstText(result) : '').toBe(''); + const { data, meta } = parseResult(result); + expect(data).toHaveLength(2); + expect(data).toContainEqual( + expect.objectContaining({ + instance: 'checkout-0', + job: 'checkout', + Value: 1, + }), + ); + expect(meta.map((column: { name: string }) => column.name)).not.toContain( + '__hdx_time_bucket', + ); + const paths = requestedUrls().map(url => url.pathname); + expect(paths).toContain('/prometheus/api/v1/query'); + expect(paths).not.toContain('/prometheus/api/v1/query_range'); + }); + + it('returns the same rows through the table function on ClickHouse < 26.6', async () => { + jest + .spyOn(Metadata.prototype, 'getServerVersion') + .mockResolvedValue([26, 5, 0, 0]); + const source = await createClickhouseSource(); + + const result = await promql({ + sourceId: source._id.toString(), + granularity: '1 minute', + }); + + expect(result.isError).toBeFalsy(); + expect(parseResult(result).data).toContainEqual({ + __hdx_time_bucket: '2023-11-14T22:14:00.000Z', + series_name: '{instance="checkout-0"}', + value: 1, + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('queries a Prometheus endpoint with the host credentials and no table params', async () => { + mockFetch.mockResolvedValueOnce( + prometheusResponse({ + resultType: 'matrix', + result: [ + { metric: { instance: 'checkout-0' }, values: [[START, '1']] }, + ], + }), + ); + const source = await createPrometheusSource(); + + const result = await promql({ sourceId: source._id.toString() }); + + expect(result.isError).toBeFalsy(); + // A lone series has no distinguishing label, so it is named after the + // expression. + expect(parseResult(result).data).toEqual([ + { + __hdx_time_bucket: '2023-11-14T22:13:20.000Z', + series_name: 'sum by (instance) (mcp_up)', + value: 1, + }, + ]); + const [url, init] = mockFetch.mock.calls[0]; + const requested = new URL(String(url)); + expect(`${requested.origin}${requested.pathname}`).toBe( + 'http://prom.example.com/api/v1/query_range', + ); + expect(requested.searchParams.get('query')).toBe( + 'sum by (instance) (mcp_up)', + ); + expect(requested.searchParams.has('database')).toBe(false); + expect(requested.searchParams.has('table')).toBe(false); + expect(init?.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('user:secret').toString('base64')}`, + }); + }); + + it('expands the PromQL macros before sending the expression', async () => { + mockFetch.mockResolvedValueOnce( + prometheusResponse({ resultType: 'matrix', result: [] }), + ); + const source = await createPrometheusSource(); + + await promql({ + sourceId: source._id.toString(), + expression: + 'rate(mcp_up[$__rate_interval]) and avg_over_time(mcp_up[$__interval])', + granularity: '1 minute', + }); + + const sent = requestedUrls()[0].searchParams.get('query'); + expect(sent).not.toContain('$__'); + expect(sent).toContain('avg_over_time(mcp_up[60s])'); + }); + + it('rejects an unknown source and a source that is not PromQL', async () => { + const unknown = await promql({ sourceId: '000000000000000000000001' }); + expect(unknown.isError).toBe(true); + expect(getFirstText(unknown)).toContain('Source not found'); + expect(getFirstText(unknown)).toContain('clickstack_list_sources'); + + const trace = await promql({ sourceId: ctx.traceSource._id.toString() }); + expect(trace.isError).toBe(true); + expect(getFirstText(trace)).toContain('Source not found'); + }); + + it("returns the endpoint's error for a rejected expression", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'error', + errorType: 'bad_data', + error: 'parse error: unexpected end of input', + }), + { status: 400 }, + ), + ); + const source = await createPrometheusSource(); + + const result = await promql({ sourceId: source._id.toString() }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('parse error'); + }); + + it('reports an unreachable endpoint without its credentials', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + const source = await createPrometheusSource(); + + const result = await promql({ sourceId: source._id.toString() }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain( + 'Failed to reach Prometheus at http://prom.example.com/api/v1/query_range', + ); + expect(getFirstText(result)).not.toContain('secret'); + }); + + it('rejects a result a chart cannot draw as a user error', async () => { + mockFetch.mockResolvedValueOnce( + prometheusResponse({ resultType: 'string', result: [START, 'hello'] }), + ); + const source = await createPrometheusSource(); + + const result = await promql({ + sourceId: source._id.toString(), + expression: '"hello"', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('returned a string'); + }); + + it('rejects a range query over the Prometheus resolution cap before querying', async () => { + const source = await createPrometheusSource(); + + const result = await promql({ + sourceId: source._id.toString(), + granularity: '1 second', + startTime: new Date((START - 86_400) * 1000).toISOString(), + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('exceeded maximum resolution'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('is not registered with PromQL disabled', async () => { + await ctx.reconnect({ promqlEnabled: false }); + const source = await createClickhouseSource(); + + const { tools } = await ctx.client!.listTools(); + expect(tools.map(tool => tool.name)).not.toContain('clickstack_promql'); + const result = await promql({ sourceId: source._id.toString() }); + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Tool clickstack_promql not found'); + }); +}); diff --git a/packages/api/src/mcp/mcpServer.ts b/packages/api/src/mcp/mcpServer.ts index 2411a704a7..c3effde282 100644 --- a/packages/api/src/mcp/mcpServer.ts +++ b/packages/api/src/mcp/mcpServer.ts @@ -15,6 +15,7 @@ import savedSearchesTools from './tools/savedSearches/index'; import sourcesTools from './tools/sources/index'; import traceTools from './tools/trace/index'; import { McpContext } from './tools/types'; +import { isPromqlEnabled } from './utils/promql'; import { createRegisterTool } from './utils/registerTool'; const SERVER_INSTRUCTIONS = [ @@ -36,6 +37,17 @@ const SERVER_INSTRUCTIONS = [ 'dashboard authoring.', ].join('\n'); +const PROMQL_INSTRUCTIONS = + 'PromQL sources (kind "promql") hold Prometheus metrics: query them with\n' + + 'clickstack_promql, not the builder tools or clickstack_sql.'; + +/** The instructions a client sees. PromQL is mentioned only while it is enabled. */ +function serverInstructions(context: McpContext): string { + return isPromqlEnabled(context) + ? `${SERVER_INSTRUCTIONS}\n\n${PROMQL_INSTRUCTIONS}` + : SERVER_INSTRUCTIONS; +} + // Served by the app; omitted when FRONTEND_URL is unset because MCP icon // sources must be absolute URLs. String concatenation (not `new URL`) keeps // any sub-path the app is mounted under. The HyperDX logo is intentional. @@ -61,7 +73,7 @@ const SERVER_INFO: Implementation = { export function createServer(context: McpContext) { const server = new McpServer(SERVER_INFO, { - instructions: SERVER_INSTRUCTIONS, + instructions: serverInstructions(context), }); const registerTool = createRegisterTool(server, context); diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 1fb3bb2f64..84c8199bb3 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -3,15 +3,28 @@ // no AI hallmarks) so the prose-lint hook treats it like any other // reviewer-facing artifact. +import { + PROMQL_DASHBOARD_SECTION, + PROMQL_QUERY_GUIDE_SECTION, +} from './promqlContent'; + +/** Which optional sections a prompt includes. */ +export type PromptOptions = { + /** Whether PromQL is enabled for this deployment. */ + promql?: boolean; +}; + export function buildCreateDashboardPrompt( sourceSummary: string, traceSourceId: string, logSourceId: string, description?: string, + options: PromptOptions = {}, ): string { const userContext = description ? `\nThe user wants to create a dashboard for: ${description}\nTailor the dashboard tiles to match this goal.\n` : ''; + const promqlSection = options.promql ? `\n\n${PROMQL_DASHBOARD_SECTION}` : ''; return `You are an expert at creating HyperDX observability dashboards. ${userContext} @@ -72,7 +85,7 @@ Use RAW SQL tiles (with connectionId) only for queries the builder cannot expres Use when you need JOINs, sub-queries, CTEs, or expressions the builder does not generate. ALWAYS set sourceId on a raw SQL tile (in addition to connectionId) UNLESS the query reads from multiple tables (e.g. JOINs across sources). sourceId enables the $__filters and - $__sourceTable macros. When you can set sourceId, include "AND $__filters" in the WHERE clause. + $__sourceTable macros. When you can set sourceId, include "AND $__filters" in the WHERE clause.${promqlSection} == COLUMN NAMING == @@ -804,8 +817,12 @@ SQL TEMPLATE REFERENCE: ); } -export function buildQueryGuidePrompt(): string { - return `Reference guide for writing queries with ClickStack MCP tools (clickstack_timeseries, clickstack_table, clickstack_search, clickstack_event_patterns, clickstack_sql, and clickstack_save_dashboard). +export function buildQueryGuidePrompt(options: PromptOptions = {}): string { + const promqlTool = options.promql ? 'clickstack_promql, ' : ''; + const promqlSection = options.promql + ? `${PROMQL_QUERY_GUIDE_SECTION}\n\n` + : ''; + return `Reference guide for writing queries with ClickStack MCP tools (clickstack_timeseries, clickstack_table, clickstack_search, clickstack_event_patterns, ${promqlTool}clickstack_sql, and clickstack_save_dashboard). == AGGREGATION FUNCTIONS (aggFn) == @@ -1040,7 +1057,7 @@ Examples: Exponential histogram p95 latency: { aggFn: "quantile", level: 0.95, metricType: "exponential histogram", metricName: "http.server.request.duration", alias: "P95 Latency" }, groupBy: "ServiceName" -== NUMBER FORMAT == +${promqlSection}== NUMBER FORMAT == numberFormat controls how a value renders. Two places to set it: diff --git a/packages/api/src/mcp/prompts/dashboards/index.ts b/packages/api/src/mcp/prompts/dashboards/index.ts index 08d0469181..69b6f60471 100644 --- a/packages/api/src/mcp/prompts/dashboards/index.ts +++ b/packages/api/src/mcp/prompts/dashboards/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getConnectionsByTeam } from '@/controllers/connection'; import { getSources } from '@/controllers/sources'; import type { PromptDefinition } from '@/mcp/tools/types'; -import { isHiddenSource } from '@/mcp/utils/promql'; +import { isHiddenSource, isPromqlEnabled } from '@/mcp/utils/promql'; import logger from '@/utils/logger'; import { @@ -19,6 +19,7 @@ import { const dashboardPrompts: PromptDefinition = (server, context) => { const { teamId } = context; + const promptOptions = { promql: isPromqlEnabled(context) }; // ── create_dashboard ────────────────────────────────────────────────────── @@ -90,6 +91,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { traceSourceId, logSourceId, description, + promptOptions, ), }, }, @@ -178,7 +180,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { description: 'Look up HyperDX query syntax: aggregation functions, ' + 'Lucene/SQL filters, raw SQL macros, column naming, ' + - 'per-tile constraints, and common mistakes.', + `per-tile constraints, ${promptOptions.promql ? 'PromQL, ' : ''}and common mistakes.`, }, async () => { return { @@ -187,7 +189,7 @@ const dashboardPrompts: PromptDefinition = (server, context) => { role: 'user' as const, content: { type: 'text' as const, - text: buildQueryGuidePrompt(), + text: buildQueryGuidePrompt(promptOptions), }, }, ], diff --git a/packages/api/src/mcp/prompts/dashboards/promqlContent.ts b/packages/api/src/mcp/prompts/dashboards/promqlContent.ts new file mode 100644 index 0000000000..88d4cb813b --- /dev/null +++ b/packages/api/src/mcp/prompts/dashboards/promqlContent.ts @@ -0,0 +1,53 @@ +// PromQL sections of the prompts, appended only while PromQL is enabled. Kept +// out of content.ts so that file does not grow further. Voice-clean like +// content.ts: no em-dashes, no AI hallmarks. + +/** Appended to the query_guide prompt. */ +export const PROMQL_QUERY_GUIDE_SECTION = `== PROMQL SOURCES == + +A source with kind "promql" holds Prometheus metrics, from a Prometheus-compatible endpoint or a ClickHouse TimeSeries table. It is queried with PromQL, not with the builder tools: clickstack_timeseries, clickstack_table and clickstack_search do not read it, and clickstack_sql only reaches a TimeSeries table's raw tags and samples. + +Discovery workflow: + 1. clickstack_list_sources: find the source with kind "promql" and its backend ("prometheus" or "clickhouse"). + 2. clickstack_describe_source(sourceId): lists its metric names and label names. + 3. clickstack_promql(sourceId, expression, startTime, endTime): run the expression. The default queryType "range" returns time series rows; queryType "instant" evaluates once at endTime and returns one row per series with a column per label (use it for "what is the value right now" and top-N questions). + 4. To keep a chart, save it as a PromQL tile (see "PROMQL TILES" in the create_dashboard prompt) and validate it with clickstack_query_tile. A tile with the same expression and granularity returns the same rows clickstack_promql did. + +Writing expressions: + Counters only ever go up, so plot their rate, never their raw value: + sum by (job) (rate(http_requests_total[$__rate_interval])) + sum by (job) (increase(http_requests_total[$__range])) total over the whole time range (instant query) + Gauges are plotted as they are: + avg by (instance) (node_memory_Active_bytes) + Error ratio: + sum by (job) (rate(http_requests_total{status=~"5.."}[$__rate_interval])) / sum by (job) (rate(http_requests_total[$__rate_interval])) + Percentiles from a histogram (aggregate by le first): + histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[$__rate_interval]))) + Label matchers: {job="checkout"} exact, {job=~"check.*"} regex, {job!="cart"} not equal. + Top-N (instant query): topk(5, sum by (instance) (rate(http_requests_total[$__rate_interval]))) + +Macros, expanded before the query is sent: + $__interval The step between points, e.g. 60s. Use it as a range: avg_over_time(metric[$__interval]). + $__rate_interval A safe range for rate() and increase(): the larger of 4 scrape intervals and $__interval plus one scrape interval. Prefer it over a fixed [5m]. + $__range The whole selected time range, e.g. 3600s. For increase(metric[$__range]) in an instant query. + +granularity sets the step of a range query and $__interval. Omit it for the automatic step (about 80 points over the range). Use the " " format, e.g. "5 minute", the same as a PromQL tile. + +Common mistakes: + - rate() on a gauge, or a raw counter without rate()/increase(). A counter reset then looks like a spike. + - A range selector shorter than the scrape interval, e.g. rate(x[15s]) on 30s scrapes: empty result. Use $__rate_interval. + - histogram_quantile without sum by (le): one quantile per series instead of one overall. + - A dashboard variable such as $job in clickstack_promql. There is no dashboard, so write the value inline; variables only work inside a PromQL tile. + - Using the builder tools or clickstack_sql for a PromQL source when clickstack_promql answers the question directly.`; + +/** Appended to the create_dashboard prompt. */ +export const PROMQL_DASHBOARD_SECTION = `== PROMQL TILES == + +Sources with kind "promql" hold Prometheus metrics. Chart them with PROMQL tiles, not builder or raw SQL tiles: + { "name": "Request rate", "config": { "configType": "promql", "displayType": "line", "sourceId": "", "expressions": [{ "expression": "sum by (job) (rate(http_requests_total{job=~\\"$job\\"}[$__rate_interval]))" }], "legendTemplate": "{{job}}" } } + + - 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. + - 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/query/index.ts b/packages/api/src/mcp/tools/query/index.ts index 1af3984f12..197ab26898 100644 --- a/packages/api/src/mcp/tools/query/index.ts +++ b/packages/api/src/mcp/tools/query/index.ts @@ -1,8 +1,10 @@ import type { ToolDefinition, ToolRegistrar } from '@/mcp/tools/types'; +import { isPromqlEnabled } from '@/mcp/utils/promql'; import { registerEmergingSignals } from './emergingSignals'; import { registerEventDeltas } from './eventDeltas'; import { registerEventPatterns } from './eventPatterns'; +import { registerPromql } from './promql'; import { registerSearch } from './search'; import { registerServiceHealth } from './serviceHealth'; import { registerSql } from './sql'; @@ -18,6 +20,10 @@ const queryTools: ToolDefinition = (registrar: ToolRegistrar) => { registerEventDeltas(registrar); registerEmergingSignals(registrar); registerSql(registrar); + // With PromQL off, PromQL sources are hidden, so there is nothing to query. + if (isPromqlEnabled(registrar.context)) { + registerPromql(registrar); + } }; export default queryTools; diff --git a/packages/api/src/mcp/tools/query/promql.ts b/packages/api/src/mcp/tools/query/promql.ts new file mode 100644 index 0000000000..da131e86ff --- /dev/null +++ b/packages/api/src/mcp/tools/query/promql.ts @@ -0,0 +1,114 @@ +import { SQLIntervalSchema } from '@hyperdx/common-utils/dist/types'; +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 { buildTile, parseTimeRange, runConfigTile } from './helpers'; +import { endTimeSchema, startTimeSchema } from './schemas'; + +// ─── Schema ────────────────────────────────────────────────────────────────── + +const promqlSchema = z.object({ + sourceId: z + .string() + .describe( + 'ID of a source whose kind is "promql". Call clickstack_list_sources to ' + + 'find it, and clickstack_describe_source for its metric and label names.', + ), + expression: z + .string() + .max(10000) + .describe( + 'PromQL expression, e.g. sum by (job) (rate(http_requests_total[$__rate_interval])). ' + + 'The $__interval, $__rate_interval and $__range macros expand as they do ' + + 'in a PromQL tile. Dashboard variables such as $job do not resolve here: ' + + 'write the value inline.', + ), + queryType: z + .enum(['range', 'instant']) + .optional() + .describe( + '"range" (the default) evaluates the expression at every step between ' + + 'startTime and endTime and returns time series rows. "instant" evaluates ' + + 'it once at endTime and returns one row per series with a column per label.', + ), + granularity: z + .union([SQLIntervalSchema, z.literal('auto')]) + .optional() + .describe( + 'Step between points of a range query, as " " with unit ' + + 'second, minute, hour or day (e.g. "30 second", "5 minute"). Same format ' + + "as a PromQL tile's granularity; also sets $__interval. Omit for the " + + 'auto step a tile would use (about 80 points over the range). Ignored ' + + 'for instant queries.', + ), + startTime: startTimeSchema, + endTime: endTimeSchema, +}); + +// ─── Tool registration ─────────────────────────────────────────────────────── + +export function registerPromql({ context, registerTool }: ToolRegistrar) { + const { teamId } = context; + + registerTool( + 'clickstack_promql', + { + title: 'PromQL Query', + annotations: READ_ONLY_TOOL, + description: + 'Run a PromQL expression against a PromQL source (a Prometheus-compatible ' + + 'endpoint or a ClickHouse TimeSeries table). Use this for every question ' + + 'about Prometheus metrics; the builder tools (clickstack_timeseries, ' + + 'clickstack_table, clickstack_search) do not read PromQL sources.\n\n' + + 'Discovery: clickstack_list_sources lists sources with kind "promql"; ' + + 'clickstack_describe_source lists their metric names and label names.\n\n' + + 'A range query (the default) returns rows { __hdx_time_bucket, ' + + 'series_name, value } plus seriesLabels, which maps each series_name to ' + + 'its labels once. An instant query returns one row per series with a ' + + 'column per label plus Value, capped at 10,000 series.\n\n' + + 'Counters need rate() or increase(), e.g. ' + + 'sum by (job) (rate(http_requests_total[$__rate_interval])). Percentiles ' + + 'from histograms: histogram_quantile(0.95, sum by (le) ' + + '(rate(http_request_duration_seconds_bucket[$__rate_interval]))). ' + + 'Fetch the query_guide prompt for more.\n\n' + + 'The result matches what a PromQL dashboard tile with the same ' + + 'expression and granularity shows, so an expression that works here can ' + + 'be saved as a tile with clickstack_save_dashboard as is.', + inputSchema: promqlSchema, + }, + async input => { + const timeRange = parseTimeRange(input.startTime, input.endTime); + if ('error' in timeRange) { + return mcpUserError(timeRange.error); + } + const { startDate, endDate } = timeRange; + + // The query runs as a one-tile dashboard that is never saved, through + // the same path as clickstack_query_tile, so results and errors match a + // saved PromQL tile. A range query is a line tile; an instant query is a + // table tile, which lays each series out with a column per label. + const tile = + input.queryType === 'instant' + ? buildTile('MCP PromQL', 24, 6, { + configType: 'promql' as const, + displayType: 'table' as const, + sourceId: input.sourceId, + expressions: [ + { expression: input.expression, queryType: 'instant' as const }, + ], + }) + : buildTile('MCP PromQL', 24, 6, { + configType: 'promql' as const, + displayType: 'line' as const, + sourceId: input.sourceId, + expressions: [{ expression: input.expression }], + granularity: input.granularity, + }); + + return runConfigTile(teamId.toString(), tile, startDate, endDate); + }, + ); +} diff --git a/packages/api/src/mcp/utils/promql.ts b/packages/api/src/mcp/utils/promql.ts index d771e05b42..29b3626baa 100644 --- a/packages/api/src/mcp/utils/promql.ts +++ b/packages/api/src/mcp/utils/promql.ts @@ -16,8 +16,8 @@ export function isHiddenSource( } export const PROMQL_QUERY_USAGE = - 'PromQL sources hold Prometheus metrics and are queried with PromQL. The ' + - 'builder query tools (clickstack_timeseries, clickstack_table, ' + + 'PromQL sources hold Prometheus metrics. Query them with clickstack_promql; ' + + 'the builder query tools (clickstack_timeseries, clickstack_table, ' + 'clickstack_search) do not read them.'; export function timeSeriesSqlUsage(table: string): string { @@ -29,6 +29,6 @@ export function timeSeriesSqlUsage(table: string): string { } export const PROMQL_SOURCE_USAGE = - `${PROMQL_QUERY_USAGE} A PromQL source with backend "clickhouse" is a ` + - `ClickHouse TimeSeries table: ${timeSeriesSqlUsage('')} ` + - 'Call clickstack_describe_source to list label names and metric names.'; + `${PROMQL_QUERY_USAGE} Call clickstack_describe_source to list label names ` + + 'and metric names. A PromQL source with backend "clickhouse" is a ' + + `ClickHouse TimeSeries table; to inspect its raw tags and samples, ${timeSeriesSqlUsage('
')}`; From 83d74dfbf36fc680c3161e420582e42391741d5d Mon Sep 17 00:00:00 2001 From: Karl Power Date: Tue, 3 Nov 2026 16:43:07 +0100 Subject: [PATCH 2/3] fix: Validate clickstack_promql sourceId as an ObjectId buildTile parses the tile with the external schema, which requires an ObjectId sourceId, so a source name threw a raw ZodError that was logged as a server error. Checking the shape in the tool schema returns a schema error to the agent instead. --- .../api/src/mcp/__tests__/promqlQueryTool.int.test.ts | 8 ++++++++ packages/api/src/mcp/tools/query/promql.ts | 11 +++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts index 14158d2b28..56079e203f 100644 --- a/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts @@ -236,6 +236,14 @@ describe('MCP clickstack_promql', () => { expect(sent).toContain('avg_over_time(mcp_up[60s])'); }); + it('rejects a sourceId that is not an ObjectId as a schema error', async () => { + const result = await promql({ sourceId: 'Metrics' }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid ObjectId'); + expect(getFirstText(result)).toContain('sourceId'); + }); + it('rejects an unknown source and a source that is not PromQL', async () => { const unknown = await promql({ sourceId: '000000000000000000000001' }); expect(unknown.isError).toBe(true); diff --git a/packages/api/src/mcp/tools/query/promql.ts b/packages/api/src/mcp/tools/query/promql.ts index da131e86ff..eace9eaa91 100644 --- a/packages/api/src/mcp/tools/query/promql.ts +++ b/packages/api/src/mcp/tools/query/promql.ts @@ -4,6 +4,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 { objectIdSchema } from '@/utils/zod'; import { buildTile, parseTimeRange, runConfigTile } from './helpers'; import { endTimeSchema, startTimeSchema } from './schemas'; @@ -11,12 +12,10 @@ import { endTimeSchema, startTimeSchema } from './schemas'; // ─── Schema ────────────────────────────────────────────────────────────────── const promqlSchema = z.object({ - sourceId: z - .string() - .describe( - 'ID of a source whose kind is "promql". Call clickstack_list_sources to ' + - 'find it, and clickstack_describe_source for its metric and label names.', - ), + sourceId: objectIdSchema.describe( + 'ID of a source whose kind is "promql". Call clickstack_list_sources to ' + + 'find it, and clickstack_describe_source for its metric and label names.', + ), expression: z .string() .max(10000) From 19fb692c6c2e51dd2510e0a1229e23e92eae0055 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Tue, 3 Nov 2026 16:50:51 +0100 Subject: [PATCH 3/3] fix: Align clickstack_promql granularity, queryType and instructions with tiles Pass granularity on instant queries too, so $__interval matches a saved table tile with the same expression. Use PromqlQueryTypeSchema for queryType instead of restating it. Reword the server instructions so clickstack_sql is positioned as the raw TimeSeries inspection route, as the source hints already say, rather than banned. --- .../mcp/__tests__/promqlQueryTool.int.test.ts | 20 +++++++++++++ packages/api/src/mcp/mcpServer.ts | 3 +- packages/api/src/mcp/tools/query/promql.ts | 29 ++++++++++--------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts index 56079e203f..54fe5e2f2e 100644 --- a/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts @@ -236,6 +236,26 @@ describe('MCP clickstack_promql', () => { expect(sent).toContain('avg_over_time(mcp_up[60s])'); }); + it('sets $__interval from granularity in an instant query too', async () => { + mockFetch.mockResolvedValueOnce( + prometheusResponse({ resultType: 'vector', result: [] }), + ); + const source = await createPrometheusSource(); + + await promql({ + sourceId: source._id.toString(), + expression: 'avg_over_time(mcp_up[$__interval])', + queryType: 'instant', + granularity: '5 minute', + }); + + const requested = requestedUrls()[0]; + expect(requested.pathname).toBe('/api/v1/query'); + expect(requested.searchParams.get('query')).toBe( + 'avg_over_time(mcp_up[300s])', + ); + }); + it('rejects a sourceId that is not an ObjectId as a schema error', async () => { const result = await promql({ sourceId: 'Metrics' }); diff --git a/packages/api/src/mcp/mcpServer.ts b/packages/api/src/mcp/mcpServer.ts index c3effde282..1e4cd4a609 100644 --- a/packages/api/src/mcp/mcpServer.ts +++ b/packages/api/src/mcp/mcpServer.ts @@ -39,7 +39,8 @@ const SERVER_INSTRUCTIONS = [ const PROMQL_INSTRUCTIONS = 'PromQL sources (kind "promql") hold Prometheus metrics: query them with\n' + - 'clickstack_promql, not the builder tools or clickstack_sql.'; + 'clickstack_promql. The builder tools do not read them; clickstack_sql only\n' + + 'inspects the raw tags and samples of a ClickHouse TimeSeries table.'; /** The instructions a client sees. PromQL is mentioned only while it is enabled. */ function serverInstructions(context: McpContext): string { diff --git a/packages/api/src/mcp/tools/query/promql.ts b/packages/api/src/mcp/tools/query/promql.ts index eace9eaa91..ac07150155 100644 --- a/packages/api/src/mcp/tools/query/promql.ts +++ b/packages/api/src/mcp/tools/query/promql.ts @@ -1,4 +1,7 @@ -import { SQLIntervalSchema } from '@hyperdx/common-utils/dist/types'; +import { + PromqlQueryTypeSchema, + SQLIntervalSchema, +} from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; @@ -25,23 +28,20 @@ const promqlSchema = z.object({ 'in a PromQL tile. Dashboard variables such as $job do not resolve here: ' + 'write the value inline.', ), - queryType: z - .enum(['range', 'instant']) - .optional() - .describe( - '"range" (the default) evaluates the expression at every step between ' + - 'startTime and endTime and returns time series rows. "instant" evaluates ' + - 'it once at endTime and returns one row per series with a column per label.', - ), + queryType: PromqlQueryTypeSchema.optional().describe( + '"range" (the default) evaluates the expression at every step between ' + + 'startTime and endTime and returns time series rows. "instant" evaluates ' + + 'it once at endTime and returns one row per series with a column per label.', + ), granularity: z .union([SQLIntervalSchema, z.literal('auto')]) .optional() .describe( - 'Step between points of a range query, as " " with unit ' + - 'second, minute, hour or day (e.g. "30 second", "5 minute"). Same format ' + - "as a PromQL tile's granularity; also sets $__interval. Omit for the " + - 'auto step a tile would use (about 80 points over the range). Ignored ' + - 'for instant queries.', + 'Step between points of a range query, and the value of $__interval in ' + + 'both query types, as " " with unit second, minute, hour ' + + 'or day (e.g. "30 second", "5 minute"). Same format as a PromQL ' + + "tile's granularity. Omit for the auto step a tile would use (about 80 " + + 'points over the range).', ), startTime: startTimeSchema, endTime: endTimeSchema, @@ -98,6 +98,7 @@ export function registerPromql({ context, registerTool }: ToolRegistrar) { expressions: [ { expression: input.expression, queryType: 'instant' as const }, ], + granularity: input.granularity, }) : buildTile('MCP PromQL', 24, 6, { configType: 'promql' as const,