Skip to content

feat: Add the clickstack_promql MCP tool and PromQL prompt docs - #3531

Merged
karl-power merged 3 commits into
claude/hdx-5302-mcp-promql-shared-chartfrom
claude/hdx-5302-mcp-promql-query
Nov 9, 2026
Merged

karl-power merged 3 commits into
claude/hdx-5302-mcp-promql-shared-chartfrom
claude/hdx-5302-mcp-promql-query

Conversation

@karl-power

Copy link
Copy Markdown
Contributor

Summary

Adds clickstack_promql, so an agent can ask a PromQL source a question directly instead of saving a dashboard tile and querying that. Also teaches the agent about PromQL through the prompts and instructions. With PromQL disabled, none of it is visible.

  • clickstack_promql(sourceId, expression, queryType?, granularity?, startTime?, endTime?). A range query (the default) returns { __hdx_time_bucket, series_name, value } rows plus seriesLabels, like clickstack_timeseries. An instant query evaluates at endTime and returns one row per series with a column per label plus Value, capped at 10,000 series.
  • The tool builds a one-tile PromQL config and runs it through runConfigTile, the same path as clickstack_query_tile and clickstack_sql. So the $__interval, $__rate_interval and $__range macros, the auto step (about 80 points, floored at the source's minAutoGranularity), the 11,000-point cap, error mapping and the 50 KB result trim are all shared, and an expression that works in the tool can be saved as a tile as is.
  • granularity takes the "<number> <unit>" format PromQL tiles and clickstack_timeseries use, so values copy between the tool and clickstack_save_dashboard unchanged.
  • The query_guide prompt gains a PROMQL SOURCES section (discovery flow, rate()/increase(), histogram_quantile, label matchers, the macros, common mistakes) and create_dashboard gains a PROMQL TILES section (tile shape, legendTemplate, PROMETHEUS_LABEL filters). Both live in a new promqlContent.ts so content.ts does not grow.
  • The server instructions end with one line sending PromQL sources to clickstack_promql, and the promqlUsage hint in clickstack_list_sources and clickstack_describe_source leads with the tool, with the clickstack_sql/timeSeriesTags route kept as the raw-inspection fallback.
  • MCP.md lists the tool and gets a "PromQL Sources" section.

How to test

  1. Call clickstack_list_sources. The PromQL source is listed and promqlUsage starts with "Query them with clickstack_promql".
  2. Call clickstack_promql with startTime one hour ago:
    { "sourceId": "<id>", "expression": "count by (job) ({__name__=~\".+\"})" }
    It returns { "__hdx_time_bucket", "series_name", "value" } rows with a seriesLabels entry per job, at about one point a minute.
  3. Repeat with "granularity": "5 minute". Points are five minutes apart.
  4. Call it as an instant query:
    { "sourceId": "<id>", "expression": "count by (job) ({__name__=~\".+\"})", "queryType": "instant" }
    One row per job, with a job column and a Value column.
  5. Send a bad expression, e.g. rate(. The error is the backend's parse error.
  6. Send "granularity": "1 second" with startTime a day ago. The error says the Prometheus resolution cap of 11,000 points was exceeded.
  7. Save the expression from step 2 as a line PromQL tile with clickstack_save_dashboard and call clickstack_query_tile on it with the same time range. The rows match step 2.
  8. Fetch the query_guide prompt: it has a == PROMQL SOURCES == section. Fetch create_dashboard: it has == PROMQL TILES ==. The server instructions (the MCP inspector shows them on connect) end with the PromQL line.
  9. Disable PromQL (restart the API with ENABLE_PROMQL=false). clickstack_promql is gone from the tool list, and the prompts and instructions no longer mention it.

References

@karl-power
karl-power requested a review from a team as a code owner November 3, 2026 15:33
@karl-power
karl-power requested review from pulpdrew and removed request for a team November 3, 2026 15:33
@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a694da0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Nov 9, 2026 5:24pm UTC
hyperdx-storybook Ready Ready Preview Nov 9, 2026 5:24pm UTC

Request Review

// table tile, which lays each series out with a column per label.
const tile =
input.queryType === 'instant'
? buildTile('MCP PromQL', 24, 6, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Instant queries drop granularity, so $__interval and $__rate_interval cannot be set and can differ from a saved tile

The instant branch builds a table tile without granularity, so $__interval and $__rate_interval in an instant expression (e.g. avg_over_time(x[$__interval])) always use the auto step. A saved PromQL table tile does accept granularity (externalPromqlChartConfigBaseSchema, packages/api/src/utils/zod.ts:690), so the same expression saved 'as is' with a granularity returns different values. This contradicts the tool description and the prompt's note that granularity sets $__interval. Fix: pass granularity: input.granularity in both branches, and change the description to say it only sets the step for range queries.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@@ -36,6 +37,17 @@ const SERVER_INSTRUCTIONS = [
'dashboard authoring.',
].join('\n');

const PROMQL_INSTRUCTIONS =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Server instructions ban clickstack_sql for PromQL sources, but the source hints recommend it

PROMQL_INSTRUCTIONS tells the agent to query PromQL sources with clickstack_promql, "not the builder tools or clickstack_sql". But PROMQL_SOURCE_USAGE (mcp/utils/promql.ts) and the query_guide section both point to clickstack_sql with timeSeriesTags/timeSeriesData as the way to inspect raw tags and samples of a TimeSeries table. Fix: reword the instruction to "prefer clickstack_promql; clickstack_sql only for raw TimeSeries inspection" so the agent gets one consistent rule.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

3 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 3 minor

1 posted as inline comment(s) on the changed lines. 2 listed below.

Findings outside the changed lines

2 minor
  • 🔵 packages/api/src/mcp/tools/query/promql.ts:814 — Tool input schema re-declares the PromQL tile's expression and granularity schemas instead of deriving them → Export and reuse the expression/granularity fields from externalPromqlRangeExpressionSchema/externalPromqlChartConfigBaseSchema in packages/api/src/utils/zod.ts (e.g. via .shape) so the tool cannot drift from the tile schema it promises to match (DRY convention: derive, don't retype).
  • 🔵 packages/api/src/mcp/tools/query/promql.ts:884 — Two near-identical buildTile branches differ only in displayType and the expression's queryType → Build one config and branch only on displayType: instant ? 'table' : 'line' and ...(instant && { queryType: 'instant' }) on the expression.

Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@karl-power
karl-power force-pushed the claude/hdx-5302-mcp-promql-query branch from 12ddc8e to cfb5dcf Compare November 3, 2026 15:37
@github-actions github-actions Bot added the review/tier-2 Low risk — AI review + quick human skim label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🔵 Tier 2 — Low Risk

Small, isolated change with no API route or data model modifications.

Why this tier:

  • Standard feature/fix — introduces new logic or modifies core functionality

Review process: AI review + quick human skim (target: 5–15 min). Reviewer validates AI assessment and checks for domain-specific concerns.
SLA: Resolve within 4 business hours.

Stats
  • Production files changed: 7
  • Production lines changed: 231 (+ 444 in test files, excluded from tier calculation)
  • Branch: claude/hdx-5302-mcp-promql-query
  • Author: karl-power

To override this classification, remove the review/tier-2 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Duplicate code analysis

No new duplicate code introduced by this PR.


jscpd compared this branch against origin/main
across app, api and common-utils. Only new clones are listed; the codebase
currently holds 110 in total (1.21%).
Run yarn dupes locally to reproduce, or yarn dupes:all for the full picture.

// ─── Schema ──────────────────────────────────────────────────────────────────

const promqlSchema = z.object({
sourceId: z

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A sourceId that isn't an ObjectId throws a ZodError from buildTile instead of returning a user error

The tool accepts z.string() here, but buildTile parses the config with externalDashboardTileSchemaWithId, whose PromQL base schema uses objectIdSchema for sourceId (packages/api/src/utils/zod.ts:688). A value like "metrics" therefore throws from .parse(). That turns into a raw Zod error, and withToolTracing counts it as a server error, so the agent never sees the friendly "Source not found … call clickstack_list_sources" message. Fix: use objectIdSchema.describe(...) for sourceId so bad IDs are rejected when the input is validated.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

'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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Restates the PromQL tile's granularity and expression schemas instead of deriving them

z.union([SQLIntervalSchema, z.literal('auto')]) and z.string().max(10000) copy externalPromqlChartConfigBaseSchema.granularity and externalPromqlRangeExpressionSchema.expression in packages/api/src/utils/zod.ts:690 and :659. The PR says the tool and the tile accept identical values, but these copies can drift apart. Fix: export those two schemas (or their .shape fields) from zod.ts and reuse them here, as the DRY rule in the repository conventions requires.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 465 passed • 1 skipped • 1211s

Status Count
✅ Passed 465
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

'find it, and clickstack_describe_source for its metric and label names.',
),
expression: z
.string()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — An empty or whitespace expression returns 'No data found' instead of an error

Use z.string().trim().min(1).max(10000) for expression. Right now getQueriedPromqlSeries (common-utils/src/core/promql.ts:43) filters out blank expressions, so runPromqlTile runs zero queries, still probes the backend, and formatQueryResult then tells the agent to widen the time range. The real problem is that the expression was empty.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

'it once at endTime and returns one row per series with a column per label.',
),
granularity: z
.union([SQLIntervalSchema, z.literal('auto')])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — granularity and expression restate the PromQL tile schema rules instead of reusing them

Export the field schemas from externalPromqlChartConfigBaseSchema / externalPromqlRangeExpressionSchema in packages/api/src/utils/zod.ts (lines 659 and 690) and reuse them here. Today the union([SQLIntervalSchema, literal('auto')]) and the string().max(10000) rules are written twice. The PR says values copy unchanged between this tool and saved tiles, so the two copies have to stay identical, and the repo's DRY rule (REQUIRED) asks for one source of truth.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const requested = requestedUrls().find(url =>
url.pathname.endsWith('/query_range'),
);
expect(Number(requested?.searchParams.get('step'))).toBeGreaterThan(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The auto-step test only checks step > 0, so it can't catch a broken auto granularity

Assert the exact step the auto rule should give for the 60s range and the source's minAutoGranularity (or compare it with buildPromqlTileQueries for the same line-tile config). As written, the test still passes if auto falls back to a fixed default or ignores the floor.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Scope: New MCP tool clickstack_promql plus PromQL prompt/instruction docs and gating. Diff base 427ed56c; 12 files (7 production, 3 test, changeset + MCP.md). Mode: report-only.

✅ No critical issues found. Correctness, security, and api-contract reviewers each traced the full registerPromql → runConfigTile → runPromqlTile path and found no defects. Tenant isolation (getSource(teamId, …)), credential redaction on Prometheus error branches, PromQL-expression injection safety (URL-encoded / bound query params), the 11,000-point resolution cap, and the enable-flag gating across tool registration, server instructions, and prompts are all correct and test-covered. The new tool reuses the shared tile-execution path, so granularity (unlike builder tiles) survives Zod parsing without re-injection — confirmed by both the correctness reviewer and the step=60 integration assertion.

🔵 P3 nitpicks (7)
  • .changeset/mcp-promql-query-tool.md:2 — a brand-new user-facing MCP tool is released as a patch, while the one in-repo precedent for adding an MCP tool (mcp-describe-table.md) used minor.

    • Fix: Consider a minor bump to match the semver guidance in AGENTS.md and the prior new-tool changeset, or confirm patch is intentional given the off-by-default ENABLE_PROMQL gate.
  • packages/api/src/mcp/tools/query/promql.ts:92 — the instant/range ternary repeats configType, sourceId, granularity, and the buildTile(...) call in both branches, where only displayType and the expression's queryType differ.

    • Fix: Collapse to one buildTile call deriving only the two differing fields from an isInstant flag so a future field addition cannot be applied to just one branch.
  • packages/api/src/mcp/mcpServer.ts:40 — PROMQL_INSTRUCTIONS restates PromQL usage text that already lives in utils/promql.ts (PROMQL_QUERY_USAGE/PROMQL_SOURCE_USAGE), with slightly divergent wording across the three surfaces.

    • Fix: Compose the server instruction from the shared constants, or co-locate the blurbs, so a tool rename updates one source of truth.
  • packages/api/src/mcp/tools/query/promql.ts:18 — sourceId uses the stricter objectIdSchema while sibling query tools use a looser string schema, so an invalid id surfaces a Zod error shape instead of the siblings' Source not found message.

    • Fix: Either adopt objectIdSchema across the sibling query tools for uniformity or document that this tool validates id format up front; intentional per the PR's own validation commit.
  • packages/api/src/mcp/tools/query/promql.ts:36 — the granularity schema (SQLIntervalSchema | 'auto') and its description diverge from sibling clickstack_timeseries, and the literal "auto" is accepted but the description only mentions omission.

    • Fix: Align the granularity schema/description across sibling query tools, or note in the description that "auto" may be passed explicitly.
  • packages/api/src/mcp/tools/query/promql.ts:36 — the "<number> <unit>" granularity format is advertised as new behavior, but no test asserts a malformed value (e.g. "5min") is rejected through the tool, unlike the bad-sourceId case which is tested.

    • Fix: Add a one-line callTool assertion that a malformed granularity surfaces a schema error; low value since SQLIntervalSchema is validated in common-utils.
  • packages/api/src/mcp/tools/query/promql.ts:92 — the tile config literal is passed into buildTile(config: Record<string, unknown>) and validated only at runtime, so the as const casts are no-ops and a field-name typo would be silently stripped rather than caught at compile time (pre-existing helper limitation the new tool inherits).

    • Fix: Annotate the config literals against the external PromQL tile input type (e.g. satisfies) or narrow buildTile's parameter so shape drift is a compile error.

Reviewers (9): correctness, security, api-contract, testing, maintainability, project-standards, kieran-typescript, previous-comments, learnings-researcher.

Coverage notes:

  • Adversarial and agent-native reviewers were dispatched but did not return before synthesis; the orchestrator independently verified the adversarial surface (granularity passthrough, instant 10,000-row cap, PromQLQueryError/PromqlChartError/ClickHouse error branches, parseTimeRange edge cases) and found no escaping failure mode.
  • Prior-comments check: the only human comment (@pulpdrew on promqlContent.ts) is an explicit advisory musing, not a change request; the jscpd duplicate-code bot comment targets packages/api/src/routers/api/prometheus.ts, which is not in this diff's file set, so it is out of scope for this commit range.
  • Testing gaps: no test exercises credential redaction on the ClickHouse-backed PromQL paths when a connection host embeds userinfo (only the Prometheus-endpoint branch is covered); create_dashboard PromQL gating is verified at the builder level but not through the live MCP client.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

@karl-power
karl-power force-pushed the claude/hdx-5302-mcp-promql-query branch from 164f4a7 to 5c65189 Compare November 3, 2026 15:52
const result = await promql({
sourceId: source._id.toString(),
granularity: '1 second',
startTime: new Date((START - 86_400) * 1000).toISOString(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Auto-step test only asserts step > 0, so a wrong auto step still passes

The PR describes the auto step as about 80 points, floored at the source's minAutoGranularity. This test only checks that some step was sent, so any regression in that logic, such as ignoring the floor or using the wrong bucket count, would pass. Assert the exact step expected for the fixed 60s range. Then add a case with minAutoGranularity set on the source and assert the step is floored to it.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const result = await promql({
sourceId: source._id.toString(),
granularity: '1 second',
startTime: new Date((START - 86_400) * 1000).toISOString(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Auto-step test only checks step > 0, so a wrong auto step still passes

Compute the expected step for the 60s range, as getTimeChartGranularity/promqlStepSeconds would with the source's minAutoGranularity, and assert that exact value. Also add a case with minAutoGranularity set to cover the floor the PR mentions. As written, a hardcoded default step, a missing floor or options.granularity being ignored would all still pass.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const result = await promql({
sourceId: source._id.toString(),
granularity: '1 second',
startTime: new Date((START - 86_400) * 1000).toISOString(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Auto-step test only asserts step > 0, so a broken auto step or a missing minAutoGranularity floor still passes

Give the source a minAutoGranularity (or use a range with a known ~80-point step) and assert the exact step value sent to /query_range.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

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.
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.
…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.
@karl-power
karl-power merged commit 7e85229 into main Nov 9, 2026
27 of 31 checks passed

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 19fb692c Deployed Nov 9, 2026 by vercel[bot]
Preview – hyperdx-storybook — 19fb692c Deployed Nov 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-2 Low risk — AI review + quick human skim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants