Skip to content

Commit 63f6286

Browse files
committed
feat: Describe PromQL sources in MCP and hide them when PromQL is disabled
describe_source returns a PromQL source's label names and metric names instead of a column schema, from a ClickHouse TimeSeries table or a Prometheus-compatible endpoint. list_sources and describe_source show each PromQL source's backend. A TimeSeries source keeps its table, with a hint for reading it through clickstack_sql's timeSeriesTags and timeSeriesData table functions. A Prometheus endpoint source drops its placeholder database and table. With ENABLE_PROMQL off, MCP hides PromQL sources from list_sources, describe_source, delete_source and the create_dashboard prompt, and save_source drops promql from its kind enum. Move joinPrometheusUpstreamUrl into a new promql controller so MCP can reach Prometheus endpoints without importing the Express router.
1 parent 5a3ad91 commit 63f6286

15 files changed

Lines changed: 647 additions & 58 deletions

File tree

‎.changeset/mcp-promql-sources.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': minor
3+
---
4+
5+
feat: Describe PromQL sources in MCP and hide them when PromQL is disabled
6+
7+
`clickstack_describe_source` now returns the label names and metric names of a PromQL source, from either a ClickHouse TimeSeries table or a Prometheus-compatible endpoint, instead of a column schema. `clickstack_list_sources` and `clickstack_describe_source` show which backend a PromQL source uses, and for TimeSeries tables explain how to read them with `clickstack_sql`. Prometheus endpoint sources no longer show their placeholder table. When `ENABLE_PROMQL` is off, MCP hides PromQL sources from listing, describing and deleting, and `clickstack_save_source` no longer accepts `kind: "promql"`.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { ClickhouseClient } from '@/clickhouse';
2+
import { getConnectionById } from '@/controllers/connection';
3+
import {
4+
queryLabelNames,
5+
queryLabelValues,
6+
} from '@/controllers/timeseriesEngine';
7+
8+
type PromqlBackend = 'prometheus' | 'clickhouse';
9+
10+
const PROMETHEUS_FETCH_TIMEOUT_MS = 30_000;
11+
12+
/**
13+
* Join a Connection host with an absolute Prometheus API path.
14+
*
15+
* `new URL('/api/v1/query_range', 'http://host:8481/select/0/prometheus')`
16+
* discards `/select/0/prometheus` because an absolute path replaces the base
17+
* pathname. VictoriaMetrics cluster (and any Prometheus-compatible server
18+
* mounted under a prefix) needs that prefix kept. Host userinfo, query, and
19+
* hash are left untouched.
20+
*
21+
* `path` must be an absolute path (every call site passes a literal starting
22+
* with `/`) -- this is not a general-purpose URL joiner.
23+
*
24+
* @see https://github.com/hyperdxio/hyperdx/issues/3046
25+
*/
26+
export function joinPrometheusUpstreamUrl(
27+
upstreamHost: string,
28+
path: string,
29+
): URL {
30+
const url = new URL(upstreamHost);
31+
// `new URL('prometheus:9090')` succeeds with an opaque path (`prometheus:`
32+
// scheme). The pathname setter is a no-op there, so without this guard the
33+
// helper would return the host unchanged, `fetch` would fail, and the proxy
34+
// would 502 / increment query_errors for a user misconfiguration. Same check
35+
// as clickhouseProxy.ts.
36+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
37+
throw new TypeError('Connection host must be http(s)');
38+
}
39+
// Strip ALL trailing slashes, not just one -- a host saved with a doubled
40+
// trailing slash (e.g. `http://prom:9090//`) would otherwise leave a `//`
41+
// in the joined path, which most servers treat as a distinct (404) path.
42+
const basePath = url.pathname.replace(/\/+$/, '');
43+
url.pathname = `${basePath}${path}`;
44+
return url;
45+
}
46+
47+
/**
48+
* GETs a Prometheus API path from an external Prometheus-compatible endpoint
49+
* and returns the response's `data`. Errors name only the origin and path: the
50+
* host may carry credentials in its userinfo or query string.
51+
*/
52+
async function fetchPrometheusData<T>(
53+
host: string,
54+
path: string,
55+
params: Record<string, string>,
56+
signal?: AbortSignal,
57+
): Promise<T> {
58+
const url = joinPrometheusUpstreamUrl(host, path);
59+
for (const [key, value] of Object.entries(params)) {
60+
url.searchParams.set(key, value);
61+
}
62+
const redactedTarget = `${url.origin}${url.pathname}`;
63+
const timeout = AbortSignal.timeout(PROMETHEUS_FETCH_TIMEOUT_MS);
64+
65+
let resp: Response;
66+
try {
67+
resp = await fetch(url, {
68+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
69+
});
70+
} catch (err) {
71+
throw new Error(
72+
err instanceof Error && err.name === 'TimeoutError'
73+
? `Prometheus request to ${redactedTarget} timed out`
74+
: `Failed to reach Prometheus at ${redactedTarget}`,
75+
);
76+
}
77+
78+
const body = (await resp.json().catch(() => undefined)) as
79+
| { status?: string; data?: T; error?: string }
80+
| undefined;
81+
if (!resp.ok || body?.status !== 'success') {
82+
throw new Error(
83+
body?.error ??
84+
`Prometheus at ${redactedTarget} returned HTTP ${resp.status}`,
85+
);
86+
}
87+
return body.data as T;
88+
}
89+
90+
export type PromqlLabelCatalog = {
91+
backend: PromqlBackend;
92+
labelNames: string[];
93+
metricNames: string[];
94+
};
95+
96+
/**
97+
* Lists the label names and metric names a PromQL source holds in
98+
* [startMs, endMs], at most `limit` of each. Returns `limit + 1` entries when
99+
* there are more, so callers can tell the list was cut.
100+
*/
101+
export async function getPromqlLabelCatalog({
102+
teamId,
103+
source,
104+
startMs,
105+
endMs,
106+
limit,
107+
signal,
108+
}: {
109+
teamId: string;
110+
source: {
111+
connection: { toString(): string };
112+
from: { databaseName: string; tableName: string };
113+
};
114+
startMs: number;
115+
endMs: number;
116+
limit: number;
117+
signal?: AbortSignal;
118+
}): Promise<PromqlLabelCatalog> {
119+
const connection = await getConnectionById(
120+
teamId,
121+
source.connection.toString(),
122+
true,
123+
);
124+
if (!connection) {
125+
throw new Error('Connection not found');
126+
}
127+
128+
if (connection.isPrometheusEndpoint) {
129+
const params = {
130+
start: String(startMs / 1000),
131+
end: String(endMs / 1000),
132+
limit: String(limit + 1),
133+
};
134+
const [labelNames, metricNames] = await Promise.all([
135+
fetchPrometheusData<string[]>(
136+
connection.host,
137+
'/api/v1/labels',
138+
params,
139+
signal,
140+
),
141+
fetchPrometheusData<string[]>(
142+
connection.host,
143+
'/api/v1/label/__name__/values',
144+
params,
145+
signal,
146+
),
147+
]);
148+
return { backend: 'prometheus', labelNames, metricNames };
149+
}
150+
151+
const args = {
152+
client: new ClickhouseClient({
153+
host: connection.host,
154+
username: connection.username,
155+
password: connection.password,
156+
requestTimeout: PROMETHEUS_FETCH_TIMEOUT_MS,
157+
}),
158+
connectionId: connection.id,
159+
databaseName: source.from.databaseName,
160+
tableName: source.from.tableName,
161+
startMs,
162+
endMs,
163+
limit: limit + 1,
164+
};
165+
const [labelNames, metricNames] = await Promise.all([
166+
queryLabelNames(args),
167+
queryLabelValues({ ...args, labelName: '__name__' }),
168+
]);
169+
return { backend: 'clickhouse', labelNames, metricNames };
170+
}

0 commit comments

Comments
 (0)