@thotischner/observability-mcp 3.1.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/mcp-2025-11-25.test.js +157 -0
- package/dist/connectors/loki.js +24 -15
- package/dist/connectors/loki.test.js +15 -0
- package/dist/connectors/prometheus.d.ts +1 -0
- package/dist/connectors/prometheus.js +75 -3
- package/dist/connectors/prometheus.test.js +81 -0
- package/dist/context.d.ts +11 -2
- package/dist/context.js +10 -2
- package/dist/context.test.js +6 -0
- package/dist/enrich/ip-dataset.d.ts +25 -0
- package/dist/enrich/ip-dataset.js +113 -0
- package/dist/enrich/ip-dataset.test.d.ts +1 -0
- package/dist/enrich/ip-dataset.test.js +85 -0
- package/dist/index.js +75 -16
- package/dist/sdk/manifest-schema.d.ts +6 -0
- package/dist/sdk/manifest-schema.js +7 -0
- package/dist/tools/enrich-ips.d.ts +30 -0
- package/dist/tools/enrich-ips.js +60 -0
- package/dist/tools/enrich-ips.test.d.ts +1 -0
- package/dist/tools/enrich-ips.test.js +38 -0
- package/dist/tools/get-anomaly-history.js +8 -1
- package/dist/tools/get-anomaly-history.test.d.ts +1 -0
- package/dist/tools/get-anomaly-history.test.js +62 -0
- package/dist/tools/handlers.test.js +15 -0
- package/dist/tools/list-services.js +7 -1
- package/dist/tools/query-logs.d.ts +5 -2
- package/dist/tools/query-logs.js +31 -13
- package/dist/tools/query-metrics.d.ts +7 -3
- package/dist/tools/query-metrics.js +33 -12
- package/dist/tools/query-raw-gate.test.d.ts +1 -0
- package/dist/tools/query-raw-gate.test.js +52 -0
- package/dist/tools/query-traces.js +7 -5
- package/dist/tools/registry-names.d.ts +1 -1
- package/dist/tools/registry-names.js +2 -0
- package/dist/tools/topology.js +14 -0
- package/dist/tools/topology.test.js +15 -0
- package/dist/tools/validation.d.ts +17 -0
- package/dist/tools/validation.js +27 -0
- package/dist/tools/validation.test.js +24 -1
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/tools/query-logs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultContext } from "../context.js";
|
|
2
|
-
import { validateDuration, validateServiceName, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
2
|
+
import { validateDuration, validateServiceName, validateLogLabels, validateLogAggregate, validateRawQuery, errorResponse } from "./validation.js";
|
|
3
3
|
export const queryLogsDefinition = {
|
|
4
4
|
name: "query_logs",
|
|
5
5
|
description: "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns. Filter by log level, a free-text/regex search, OR structured `labels` (exact-match on backend-extracted fields like method/status/url/environment — far more reliable than regex on structured JSON logs).",
|
|
@@ -46,20 +46,37 @@ export const queryLogsDefinition = {
|
|
|
46
46
|
required: ["service"],
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
|
-
export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
50
|
-
const svcErr = validateServiceName(args.service);
|
|
51
|
-
if (svcErr)
|
|
52
|
-
return errorResponse(svcErr);
|
|
49
|
+
export async function queryLogsHandler(registry, args, ctx = defaultContext(), opts = {}) {
|
|
53
50
|
const duration = args.duration || "5m";
|
|
54
51
|
const durationErr = validateDuration(duration);
|
|
55
52
|
if (durationErr)
|
|
56
53
|
return errorResponse(durationErr);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
return errorResponse(
|
|
54
|
+
// Raw LogQL passthrough — capability-gated, default off. Bypasses the curated
|
|
55
|
+
// stream-selector construction, so `service` is not required and is ignored.
|
|
56
|
+
// Mutually exclusive with `aggregate` (for metric LogQL use `aggregate`).
|
|
57
|
+
const rawErr = validateRawQuery(args.raw_query);
|
|
58
|
+
if (rawErr)
|
|
59
|
+
return errorResponse(rawErr);
|
|
60
|
+
const isRaw = !!args.raw_query;
|
|
61
|
+
if (isRaw && !opts.allowRawQuery) {
|
|
62
|
+
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim LogQL — it bypasses the curated log surface, so it is off by default.");
|
|
63
|
+
}
|
|
64
|
+
if (isRaw && args.aggregate) {
|
|
65
|
+
return errorResponse("raw_query and aggregate are mutually exclusive — a raw LogQL query expresses its own aggregation.");
|
|
66
|
+
}
|
|
67
|
+
if (!isRaw) {
|
|
68
|
+
if (!args.service)
|
|
69
|
+
return errorResponse("service is required (or set raw_query).");
|
|
70
|
+
const svcErr = validateServiceName(args.service);
|
|
71
|
+
if (svcErr)
|
|
72
|
+
return errorResponse(svcErr);
|
|
73
|
+
const labelsErr = validateLogLabels(args.labels);
|
|
74
|
+
if (labelsErr)
|
|
75
|
+
return errorResponse(labelsErr);
|
|
76
|
+
const aggErr = validateLogAggregate(args.aggregate);
|
|
77
|
+
if (aggErr)
|
|
78
|
+
return errorResponse(aggErr);
|
|
79
|
+
}
|
|
63
80
|
const connectors = registry.getByTenant(ctx.tenant).filter((c) => c.signalType === "logs");
|
|
64
81
|
if (connectors.length === 0) {
|
|
65
82
|
return {
|
|
@@ -80,7 +97,7 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
80
97
|
capable++;
|
|
81
98
|
try {
|
|
82
99
|
const q = {
|
|
83
|
-
service: args.service,
|
|
100
|
+
service: args.service ?? "",
|
|
84
101
|
duration,
|
|
85
102
|
labels: args.labels,
|
|
86
103
|
query: args.query,
|
|
@@ -119,12 +136,13 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
119
136
|
continue;
|
|
120
137
|
try {
|
|
121
138
|
const result = await connector.queryLogs({
|
|
122
|
-
service: args.service,
|
|
139
|
+
service: args.service ?? "",
|
|
123
140
|
query: args.query,
|
|
124
141
|
duration,
|
|
125
142
|
level: args.level,
|
|
126
143
|
limit: args.limit,
|
|
127
144
|
labels: args.labels,
|
|
145
|
+
rawQuery: args.raw_query,
|
|
128
146
|
});
|
|
129
147
|
results.push(result);
|
|
130
148
|
}
|
|
@@ -31,12 +31,16 @@ export declare const queryMetricsDefinition: {
|
|
|
31
31
|
};
|
|
32
32
|
};
|
|
33
33
|
export declare function queryMetricsHandler(registry: ConnectorRegistry, args: {
|
|
34
|
-
service
|
|
35
|
-
metric
|
|
34
|
+
service?: string;
|
|
35
|
+
metric?: string;
|
|
36
36
|
duration?: string;
|
|
37
37
|
source?: string;
|
|
38
38
|
groupBy?: string;
|
|
39
|
-
|
|
39
|
+
labels?: Record<string, string>;
|
|
40
|
+
raw_query?: string;
|
|
41
|
+
}, ctx?: RequestContext, opts?: {
|
|
42
|
+
allowRawQuery?: boolean;
|
|
43
|
+
}): Promise<{
|
|
40
44
|
content: {
|
|
41
45
|
type: "text";
|
|
42
46
|
text: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultContext } from "../context.js";
|
|
2
|
-
import { validateDuration, validateMetricName, validateServiceName, errorResponse } from "./validation.js";
|
|
2
|
+
import { validateDuration, validateMetricName, validateServiceName, validateMetricLabels, validateRawQuery, errorResponse } from "./validation.js";
|
|
3
3
|
export const queryMetricsDefinition = {
|
|
4
4
|
name: "query_metrics",
|
|
5
5
|
description: "Query a specific metric for a service over a given timeframe. Returns time-series data with pre-computed summary statistics (current, average, min, max, trend). Available metrics: cpu, memory, error_rate, request_rate, latency_p99, latency_p50, latency_avg.",
|
|
@@ -30,7 +30,7 @@ export const queryMetricsDefinition = {
|
|
|
30
30
|
required: ["service", "metric"],
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
|
-
export async function queryMetricsHandler(registry, args, ctx = defaultContext()) {
|
|
33
|
+
export async function queryMetricsHandler(registry, args, ctx = defaultContext(), opts = {}) {
|
|
34
34
|
// Coarse single-tenant source scoping: if the principal is restricted to a
|
|
35
35
|
// source allow-list, deny an explicit out-of-scope source.
|
|
36
36
|
if (ctx.allowedSources &&
|
|
@@ -38,18 +38,37 @@ export async function queryMetricsHandler(registry, args, ctx = defaultContext()
|
|
|
38
38
|
!ctx.allowedSources.includes(args.source)) {
|
|
39
39
|
return errorResponse(`forbidden: source "${args.source}" is not in your allowed sources`);
|
|
40
40
|
}
|
|
41
|
-
const svcErr = validateServiceName(args.service);
|
|
42
|
-
if (svcErr)
|
|
43
|
-
return errorResponse(svcErr);
|
|
44
41
|
const duration = args.duration || "5m";
|
|
45
42
|
const durationErr = validateDuration(duration);
|
|
46
43
|
if (durationErr)
|
|
47
44
|
return errorResponse(durationErr);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
// Raw PromQL passthrough — capability-gated, default off. Bypasses the
|
|
46
|
+
// curated metric catalog/selector, so service/metric/groupBy/labels are not
|
|
47
|
+
// required and are ignored. Still tenant-scoped + source-allow-listed below.
|
|
48
|
+
const rawErr = validateRawQuery(args.raw_query);
|
|
49
|
+
if (rawErr)
|
|
50
|
+
return errorResponse(rawErr);
|
|
51
|
+
const isRaw = !!args.raw_query;
|
|
52
|
+
if (isRaw && !opts.allowRawQuery) {
|
|
53
|
+
return errorResponse("raw_query is disabled. The operator must enable the raw-query capability (OMCP_RAW_QUERY=on) to run verbatim PromQL — it bypasses the curated metric surface, so it is off by default.");
|
|
54
|
+
}
|
|
55
|
+
if (!isRaw) {
|
|
56
|
+
if (!args.service)
|
|
57
|
+
return errorResponse("service is required (or set raw_query).");
|
|
58
|
+
const svcErr = validateServiceName(args.service);
|
|
59
|
+
if (svcErr)
|
|
60
|
+
return errorResponse(svcErr);
|
|
61
|
+
if (!args.metric)
|
|
62
|
+
return errorResponse("metric is required (or set raw_query).");
|
|
63
|
+
const metricErr = validateMetricName(args.metric, registry);
|
|
64
|
+
if (metricErr)
|
|
65
|
+
return errorResponse(metricErr);
|
|
66
|
+
if (args.groupBy && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(args.groupBy)) {
|
|
67
|
+
return errorResponse(`Invalid groupBy "${args.groupBy}". Must be a valid Prometheus label name (alphanumeric + underscore, starting with letter/underscore).`);
|
|
68
|
+
}
|
|
69
|
+
const labelsErr = validateMetricLabels(args.labels);
|
|
70
|
+
if (labelsErr)
|
|
71
|
+
return errorResponse(labelsErr);
|
|
53
72
|
}
|
|
54
73
|
// Tenant-scoped resolution: an explicit `source` from the agent
|
|
55
74
|
// must belong to the caller's tenant (or be a global / untagged
|
|
@@ -80,10 +99,12 @@ export async function queryMetricsHandler(registry, args, ctx = defaultContext()
|
|
|
80
99
|
continue;
|
|
81
100
|
try {
|
|
82
101
|
const result = await connector.queryMetrics({
|
|
83
|
-
service: args.service,
|
|
84
|
-
metric: args.metric,
|
|
102
|
+
service: args.service ?? "",
|
|
103
|
+
metric: args.metric ?? "",
|
|
85
104
|
duration,
|
|
86
105
|
groupBy: args.groupBy,
|
|
106
|
+
labels: args.labels,
|
|
107
|
+
rawQuery: args.raw_query,
|
|
87
108
|
});
|
|
88
109
|
results.push(result);
|
|
89
110
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ConnectorRegistry } from "../connectors/registry.js";
|
|
4
|
+
import { PluginLoader } from "../connectors/loader.js";
|
|
5
|
+
import { queryMetricsHandler } from "./query-metrics.js";
|
|
6
|
+
import { queryLogsHandler } from "./query-logs.js";
|
|
7
|
+
// R4 (issue #415 #3): raw_query is an escape hatch that bypasses the curated
|
|
8
|
+
// metric/log surface, so it MUST be refused unless the operator enabled the
|
|
9
|
+
// capability (opts.allowRawQuery, driven by OMCP_RAW_QUERY). These tests pin
|
|
10
|
+
// the gate: the denial fires before any backend is touched, so an empty
|
|
11
|
+
// registry is enough — and with the capability ON the call proceeds past the
|
|
12
|
+
// gate to the normal "no backend configured" path.
|
|
13
|
+
function parse(result) {
|
|
14
|
+
return JSON.parse(result.content[0].text);
|
|
15
|
+
}
|
|
16
|
+
describe("raw_query capability gate", () => {
|
|
17
|
+
const emptyRegistry = () => new ConnectorRegistry(new PluginLoader());
|
|
18
|
+
it("query_metrics refuses raw_query when capability is off (default)", async () => {
|
|
19
|
+
const out = parse(await queryMetricsHandler(emptyRegistry(), { raw_query: "up" }, undefined, { allowRawQuery: false }));
|
|
20
|
+
assert.match(out.error, /raw_query is disabled/i);
|
|
21
|
+
assert.match(out.error, /OMCP_RAW_QUERY/);
|
|
22
|
+
});
|
|
23
|
+
it("query_metrics defaults to refusing raw_query when no opts passed", async () => {
|
|
24
|
+
const out = parse(await queryMetricsHandler(emptyRegistry(), { raw_query: "up" }));
|
|
25
|
+
assert.match(out.error, /raw_query is disabled/i);
|
|
26
|
+
});
|
|
27
|
+
it("query_logs refuses raw_query when capability is off (default)", async () => {
|
|
28
|
+
const out = parse(await queryLogsHandler(emptyRegistry(), { raw_query: '{job="x"}' }, undefined, { allowRawQuery: false }));
|
|
29
|
+
assert.match(out.error, /raw_query is disabled/i);
|
|
30
|
+
});
|
|
31
|
+
it("query_metrics passes the gate when capability is on (reaches backend resolution)", async () => {
|
|
32
|
+
const out = parse(await queryMetricsHandler(emptyRegistry(), { raw_query: "up" }, undefined, { allowRawQuery: true }));
|
|
33
|
+
// Past the gate → normal no-backend path, NOT the capability denial.
|
|
34
|
+
assert.doesNotMatch(out.error ?? "", /raw_query is disabled/i);
|
|
35
|
+
assert.match(out.error, /No metrics backends configured/i);
|
|
36
|
+
});
|
|
37
|
+
it("query_logs passes the gate when capability is on (reaches backend resolution)", async () => {
|
|
38
|
+
const out = parse(await queryLogsHandler(emptyRegistry(), { raw_query: '{job="x"}' }, undefined, { allowRawQuery: true }));
|
|
39
|
+
assert.doesNotMatch(out.error ?? "", /raw_query is disabled/i);
|
|
40
|
+
assert.match(out.error, /No log backends configured/i);
|
|
41
|
+
});
|
|
42
|
+
it("query_logs rejects raw_query + aggregate as mutually exclusive", async () => {
|
|
43
|
+
const out = parse(await queryLogsHandler(emptyRegistry(), { raw_query: '{job="x"}', aggregate: { op: "count_over_time" } }, undefined, { allowRawQuery: true }));
|
|
44
|
+
assert.match(out.error, /mutually exclusive/i);
|
|
45
|
+
});
|
|
46
|
+
it("normal (non-raw) calls are unaffected by the capability flag", async () => {
|
|
47
|
+
// No raw_query → gate is a no-op even with capability off; falls through
|
|
48
|
+
// to normal validation/backend path.
|
|
49
|
+
const out = parse(await queryMetricsHandler(emptyRegistry(), { service: "api", metric: "cpu" }, undefined, { allowRawQuery: false }));
|
|
50
|
+
assert.doesNotMatch(out.error ?? "", /raw_query/i);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
// summaries, and recomputes a global p50/p95 over the merged set
|
|
7
7
|
// (rather than blindly averaging per-source summaries).
|
|
8
8
|
//
|
|
9
|
-
// Backend support
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Backend support: no traces backend is bundled by default. The Tempo
|
|
10
|
+
// connector ships in the connector hub (install it to enable traces);
|
|
11
|
+
// there is no Jaeger connector today. Any connector that implements the
|
|
12
|
+
// optional queryTraces capability participates automatically — so on a
|
|
13
|
+
// stack without one the tool returns a clean "No trace backends
|
|
14
|
+
// configured" result rather than failing.
|
|
13
15
|
import { defaultContext } from "../context.js";
|
|
14
16
|
import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
|
|
15
17
|
export const queryTracesDefinition = {
|
|
@@ -18,7 +20,7 @@ export const queryTracesDefinition = {
|
|
|
18
20
|
"Query distributed traces for a service over a given timeframe.",
|
|
19
21
|
"Returns ranked trace summaries with duration, error status, and span count, plus a p50/p95 duration aggregate across the returned set.",
|
|
20
22
|
"When to use: investigating tail-latency outliers, walking call chains across services for a known time window, or pulling related traces for an anomaly the metric/log tools surfaced first.",
|
|
21
|
-
"Behavior: read-only; results may be capped via `limit` (default 50). `filter` accepts the backend's native query language (TraceQL on Tempo
|
|
23
|
+
"Behavior: read-only; results may be capped via `limit` (default 50). `filter` accepts the backend's native query language (e.g. TraceQL on Tempo). When `errorsOnly=true`, only traces with at least one error span are returned.",
|
|
22
24
|
"Related: `query_metrics` for the per-service latency series; `get_blast_radius` for the topology a trace traverses.",
|
|
23
25
|
].join(" "),
|
|
24
26
|
inputSchema: {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Keep this list and the registerTool("name", ...) calls in
|
|
14
14
|
* createMcpServer in sync. The test enforces it.
|
|
15
15
|
*/
|
|
16
|
-
export declare const REGISTERED_TOOL_NAMES: readonly ["list_sources", "list_services", "query_metrics", "query_logs", "query_traces", "get_service_health", "detect_anomalies", "get_anomaly_history", "generate_postmortem", "get_topology", "get_blast_radius"];
|
|
16
|
+
export declare const REGISTERED_TOOL_NAMES: readonly ["list_sources", "list_services", "query_metrics", "query_logs", "query_traces", "get_service_health", "detect_anomalies", "get_anomaly_history", "generate_postmortem", "get_topology", "get_blast_radius", "enrich_ips"];
|
|
17
17
|
export type RegisteredToolName = typeof REGISTERED_TOOL_NAMES[number];
|
|
18
18
|
/** Functional category of a tool, surfaced in /api/tools/registry and
|
|
19
19
|
* used by the Products UI to group the multi-select picker. Keeps
|
|
@@ -25,6 +25,7 @@ export const REGISTERED_TOOL_NAMES = [
|
|
|
25
25
|
"generate_postmortem",
|
|
26
26
|
"get_topology",
|
|
27
27
|
"get_blast_radius",
|
|
28
|
+
"enrich_ips",
|
|
28
29
|
];
|
|
29
30
|
export const REGISTERED_TOOLS = [
|
|
30
31
|
{ name: "list_sources", category: "discovery", summary: "List configured observability backends + reachability." },
|
|
@@ -38,6 +39,7 @@ export const REGISTERED_TOOLS = [
|
|
|
38
39
|
{ name: "generate_postmortem", category: "diagnose", summary: "One-shot markdown post-mortem stitching anomaly history + traces + blast-radius + logs for a service." },
|
|
39
40
|
{ name: "get_topology", category: "topology", summary: "Return the infrastructure topology graph (resources + edges)." },
|
|
40
41
|
{ name: "get_blast_radius", category: "topology", summary: "Given a resource, return the impact set if its host(s) fail." },
|
|
42
|
+
{ name: "enrich_ips", category: "query", summary: "Resolve IPv4 addresses to geo/ASN/org/hosting-flag from a local offline dataset." },
|
|
41
43
|
];
|
|
42
44
|
/** Validate a candidate Product tools[] array. Returns the unknown
|
|
43
45
|
* names (empty array = all OK). Pure helper — the caller decides
|
package/dist/tools/topology.js
CHANGED
|
@@ -146,6 +146,20 @@ export async function getTopologyHandler(registry, args = {}, ctx = defaultConte
|
|
|
146
146
|
total: { resources: agg.resources.length, edges: agg.edges.length },
|
|
147
147
|
truncated,
|
|
148
148
|
};
|
|
149
|
+
// Signal vs. silence: when NO topology-capable connector contributed a
|
|
150
|
+
// snapshot, an empty {resources:[],edges:[]} is ambiguous to an agent —
|
|
151
|
+
// it can't tell "graph is genuinely empty" from "no topology backend is
|
|
152
|
+
// wired up". Mirror query_traces' explicit "no backend" message so the
|
|
153
|
+
// agent gets a clear signal instead of silence (issue #415).
|
|
154
|
+
if (agg.sources.length === 0) {
|
|
155
|
+
payload.note =
|
|
156
|
+
"No topology-capable connector is configured, so the graph is empty. " +
|
|
157
|
+
"Topology comes from connectors like the built-in `kubernetes` source " +
|
|
158
|
+
"or the aws/gcp/istio/linkerd/consul providers — add one (see the " +
|
|
159
|
+
"Sources tab or docs/plugin-architecture) to populate this graph. " +
|
|
160
|
+
"A deployment with only metrics/logs backends (e.g. Prometheus/Loki) " +
|
|
161
|
+
"has no topology to report here.";
|
|
162
|
+
}
|
|
149
163
|
return {
|
|
150
164
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
151
165
|
};
|
|
@@ -153,6 +153,21 @@ describe("get_topology tool", () => {
|
|
|
153
153
|
assert.equal(out.truncated, true);
|
|
154
154
|
assert.equal(out.total.resources, fixture().resources.length);
|
|
155
155
|
});
|
|
156
|
+
it("does not attach the no-connector note when topology is present", async () => {
|
|
157
|
+
const reg = await makeRegistry();
|
|
158
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
159
|
+
assert.equal(out.note, undefined);
|
|
160
|
+
});
|
|
161
|
+
it("returns an explicit note when no topology connector is configured (issue #415)", async () => {
|
|
162
|
+
// Empty registry — no topology-capable connector. The agent must get a
|
|
163
|
+
// clear signal, not a silent empty graph.
|
|
164
|
+
const reg = new ConnectorRegistry(new PluginLoader());
|
|
165
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
166
|
+
assert.deepEqual(out.resources, []);
|
|
167
|
+
assert.deepEqual(out.edges, []);
|
|
168
|
+
assert.equal(out.sources.length, 0);
|
|
169
|
+
assert.match(out.note, /no topology-capable connector/i);
|
|
170
|
+
});
|
|
156
171
|
});
|
|
157
172
|
describe("get_blast_radius tool", () => {
|
|
158
173
|
it("reports shared-host blast radius for a co-located pod", async () => {
|
|
@@ -16,6 +16,23 @@ export declare function validateServiceName(service: string): string | null;
|
|
|
16
16
|
* can't build a pathological query.
|
|
17
17
|
*/
|
|
18
18
|
export declare function validateLogLabels(labels: unknown): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Validate a structured `labels` filter map for query_metrics. The rules are
|
|
21
|
+
* identical to the log-label validator (valid Prometheus label names, bounded
|
|
22
|
+
* map size + value length, fail-closed) — metric labels compile to PromQL
|
|
23
|
+
* label-equality matchers and the values are escaped for PromQL at injection
|
|
24
|
+
* time, exactly as the log path escapes for LogQL.
|
|
25
|
+
*/
|
|
26
|
+
export declare const validateMetricLabels: typeof validateLogLabels;
|
|
27
|
+
/**
|
|
28
|
+
* Validate a raw PromQL/LogQL passthrough string. The capability gate (is raw
|
|
29
|
+
* query allowed at all) lives at the handler; this only bounds the shape:
|
|
30
|
+
* non-empty string, length-capped so a crafted query can't build a
|
|
31
|
+
* pathological request. The query is sent verbatim to the backend (that is the
|
|
32
|
+
* point of a passthrough), so there is no syntax check — an invalid query just
|
|
33
|
+
* yields the backend's own parse error.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateRawQuery(raw: unknown): string | null;
|
|
19
36
|
/**
|
|
20
37
|
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
21
38
|
* validator. Returns an error string or null.
|
package/dist/tools/validation.js
CHANGED
|
@@ -73,6 +73,33 @@ export function validateLogLabels(labels) {
|
|
|
73
73
|
}
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate a structured `labels` filter map for query_metrics. The rules are
|
|
78
|
+
* identical to the log-label validator (valid Prometheus label names, bounded
|
|
79
|
+
* map size + value length, fail-closed) — metric labels compile to PromQL
|
|
80
|
+
* label-equality matchers and the values are escaped for PromQL at injection
|
|
81
|
+
* time, exactly as the log path escapes for LogQL.
|
|
82
|
+
*/
|
|
83
|
+
export const validateMetricLabels = validateLogLabels;
|
|
84
|
+
/**
|
|
85
|
+
* Validate a raw PromQL/LogQL passthrough string. The capability gate (is raw
|
|
86
|
+
* query allowed at all) lives at the handler; this only bounds the shape:
|
|
87
|
+
* non-empty string, length-capped so a crafted query can't build a
|
|
88
|
+
* pathological request. The query is sent verbatim to the backend (that is the
|
|
89
|
+
* point of a passthrough), so there is no syntax check — an invalid query just
|
|
90
|
+
* yields the backend's own parse error.
|
|
91
|
+
*/
|
|
92
|
+
export function validateRawQuery(raw) {
|
|
93
|
+
if (raw === undefined)
|
|
94
|
+
return null;
|
|
95
|
+
if (typeof raw !== "string")
|
|
96
|
+
return "raw_query must be a string.";
|
|
97
|
+
if (raw.trim().length === 0)
|
|
98
|
+
return "raw_query must not be empty.";
|
|
99
|
+
if (raw.length > 8192)
|
|
100
|
+
return "raw_query too long (max 8192 chars).";
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
76
103
|
const AGGREGATE_OPS = new Set(["count_over_time", "sum", "topk"]);
|
|
77
104
|
/**
|
|
78
105
|
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { validateDuration, validateServiceName, sanitizeLabelValue, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
3
|
+
import { validateDuration, validateServiceName, sanitizeLabelValue, validateLogLabels, validateLogAggregate, validateMetricLabels, validateRawQuery, errorResponse } from "./validation.js";
|
|
4
4
|
describe("validateLogAggregate (Q-LOG2)", () => {
|
|
5
5
|
it("accepts undefined and valid specs", () => {
|
|
6
6
|
assert.equal(validateLogAggregate(undefined), null);
|
|
@@ -54,6 +54,29 @@ describe("validateLogLabels (Q-LOG1)", () => {
|
|
|
54
54
|
assert.ok(validateLogLabels(many));
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
|
+
describe("validateRawQuery (R4, issue #415 #3)", () => {
|
|
58
|
+
it("accepts undefined (not requested) and a normal query", () => {
|
|
59
|
+
assert.equal(validateRawQuery(undefined), null);
|
|
60
|
+
assert.equal(validateRawQuery('sum(rate(http_requests_total[5m]))'), null);
|
|
61
|
+
});
|
|
62
|
+
it("rejects non-strings, empty/whitespace, and over-long", () => {
|
|
63
|
+
assert.ok(validateRawQuery(42));
|
|
64
|
+
assert.ok(validateRawQuery(""));
|
|
65
|
+
assert.ok(validateRawQuery(" "));
|
|
66
|
+
assert.ok(validateRawQuery("x".repeat(8193)));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("validateMetricLabels (R3, issue #415 #4)", () => {
|
|
70
|
+
it("accepts undefined and a valid map (same rules as log labels)", () => {
|
|
71
|
+
assert.equal(validateMetricLabels(undefined), null);
|
|
72
|
+
assert.equal(validateMetricLabels({ status: "500", route: "/checkout" }), null);
|
|
73
|
+
});
|
|
74
|
+
it("rejects invalid label names and bad values, fail-closed", () => {
|
|
75
|
+
assert.ok(validateMetricLabels({ "a-b": "x" }));
|
|
76
|
+
assert.ok(validateMetricLabels({ status: 500 }));
|
|
77
|
+
assert.ok(validateMetricLabels("nope"));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
57
80
|
describe("validateDuration", () => {
|
|
58
81
|
it("accepts valid durations", () => {
|
|
59
82
|
assert.equal(validateDuration("5m"), null);
|
package/dist/types.d.ts
CHANGED
|
@@ -102,6 +102,11 @@ export interface MetricQuery {
|
|
|
102
102
|
duration: string;
|
|
103
103
|
step?: string;
|
|
104
104
|
groupBy?: string;
|
|
105
|
+
labels?: Record<string, string>;
|
|
106
|
+
/** Raw PromQL, run verbatim over query_range — bypasses the curated metric
|
|
107
|
+
* catalog/selector. When set, `metric`/`groupBy`/`labels`/`service` are
|
|
108
|
+
* ignored. Gated by the OMCP_RAW_QUERY capability at the tool layer. */
|
|
109
|
+
rawQuery?: string;
|
|
105
110
|
}
|
|
106
111
|
export interface LogQuery {
|
|
107
112
|
service: string;
|
|
@@ -115,6 +120,11 @@ export interface LogQuery {
|
|
|
115
120
|
* environment, …) become first-class selectors instead of brittle
|
|
116
121
|
* free-text regex. */
|
|
117
122
|
labels?: Record<string, string>;
|
|
123
|
+
/** Raw LogQL log-selector query, run verbatim — bypasses the curated
|
|
124
|
+
* stream-selector/pipeline construction. When set, `service`/`labels`/
|
|
125
|
+
* `level`/`query` are ignored. Gated by the OMCP_RAW_QUERY capability at
|
|
126
|
+
* the tool layer. For metric LogQL (count_over_time/…) use `aggregate`. */
|
|
127
|
+
rawQuery?: string;
|
|
118
128
|
}
|
|
119
129
|
/** Server-side log aggregation (Q-LOG2). Pushes count/group/topk down to
|
|
120
130
|
* the backend's metric-query path so an agent gets a *number*, not a
|
package/package.json
CHANGED