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..54fe5e2f2e --- /dev/null +++ b/packages/api/src/mcp/__tests__/promqlQueryTool.int.test.ts @@ -0,0 +1,349 @@ +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('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' }); + + 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); + 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..1e4cd4a609 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,18 @@ const SERVER_INSTRUCTIONS = [ 'dashboard authoring.', ].join('\n'); +const PROMQL_INSTRUCTIONS = + 'PromQL sources (kind "promql") hold Prometheus metrics: query them with\n' + + '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 { + 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 +74,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..ac07150155 --- /dev/null +++ b/packages/api/src/mcp/tools/query/promql.ts @@ -0,0 +1,114 @@ +import { + PromqlQueryTypeSchema, + 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 { objectIdSchema } from '@/utils/zod'; + +import { buildTile, parseTimeRange, runConfigTile } from './helpers'; +import { endTimeSchema, startTimeSchema } from './schemas'; + +// ─── Schema ────────────────────────────────────────────────────────────────── + +const promqlSchema = z.object({ + 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) + .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: 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, 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, +}); + +// ─── 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 }, + ], + granularity: input.granularity, + }) + : 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('
')}`;