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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-promql-query-tool.md
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `"<number> <unit>"` 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": "<promql source id>",
"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.
47 changes: 45 additions & 2 deletions packages/api/src/mcp/__tests__/dashboards/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/api/src/mcp/__tests__/promqlGating.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof connect>>) => {
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');
});
});
Loading
Loading