@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.
Files changed (41) hide show
  1. package/dist/conformance/mcp-2025-11-25.test.js +157 -0
  2. package/dist/connectors/loki.js +24 -15
  3. package/dist/connectors/loki.test.js +15 -0
  4. package/dist/connectors/prometheus.d.ts +1 -0
  5. package/dist/connectors/prometheus.js +75 -3
  6. package/dist/connectors/prometheus.test.js +81 -0
  7. package/dist/context.d.ts +11 -2
  8. package/dist/context.js +10 -2
  9. package/dist/context.test.js +6 -0
  10. package/dist/enrich/ip-dataset.d.ts +25 -0
  11. package/dist/enrich/ip-dataset.js +113 -0
  12. package/dist/enrich/ip-dataset.test.d.ts +1 -0
  13. package/dist/enrich/ip-dataset.test.js +85 -0
  14. package/dist/index.js +75 -16
  15. package/dist/sdk/manifest-schema.d.ts +6 -0
  16. package/dist/sdk/manifest-schema.js +7 -0
  17. package/dist/tools/enrich-ips.d.ts +30 -0
  18. package/dist/tools/enrich-ips.js +60 -0
  19. package/dist/tools/enrich-ips.test.d.ts +1 -0
  20. package/dist/tools/enrich-ips.test.js +38 -0
  21. package/dist/tools/get-anomaly-history.js +8 -1
  22. package/dist/tools/get-anomaly-history.test.d.ts +1 -0
  23. package/dist/tools/get-anomaly-history.test.js +62 -0
  24. package/dist/tools/handlers.test.js +15 -0
  25. package/dist/tools/list-services.js +7 -1
  26. package/dist/tools/query-logs.d.ts +5 -2
  27. package/dist/tools/query-logs.js +31 -13
  28. package/dist/tools/query-metrics.d.ts +7 -3
  29. package/dist/tools/query-metrics.js +33 -12
  30. package/dist/tools/query-raw-gate.test.d.ts +1 -0
  31. package/dist/tools/query-raw-gate.test.js +52 -0
  32. package/dist/tools/query-traces.js +7 -5
  33. package/dist/tools/registry-names.d.ts +1 -1
  34. package/dist/tools/registry-names.js +2 -0
  35. package/dist/tools/topology.js +14 -0
  36. package/dist/tools/topology.test.js +15 -0
  37. package/dist/tools/validation.d.ts +17 -0
  38. package/dist/tools/validation.js +27 -0
  39. package/dist/tools/validation.test.js +24 -1
  40. package/dist/types.d.ts +10 -0
  41. package/package.json +1 -1
@@ -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
- const labelsErr = validateLogLabels(args.labels);
58
- if (labelsErr)
59
- return errorResponse(labelsErr);
60
- const aggErr = validateLogAggregate(args.aggregate);
61
- if (aggErr)
62
- return errorResponse(aggErr);
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: string;
35
- metric: string;
34
+ service?: string;
35
+ metric?: string;
36
36
  duration?: string;
37
37
  source?: string;
38
38
  groupBy?: string;
39
- }, ctx?: RequestContext): Promise<{
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
- const metricErr = validateMetricName(args.metric, registry);
49
- if (metricErr)
50
- return errorResponse(metricErr);
51
- if (args.groupBy && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(args.groupBy)) {
52
- return errorResponse(`Invalid groupBy "${args.groupBy}". Must be a valid Prometheus label name (alphanumeric + underscore, starting with letter/underscore).`);
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 today: a Tempo connector + a Jaeger shim ship as
10
- // filesystem plugins. Any connector that implements queryTraces
11
- // participates automatically no changes needed in the tool layer
12
- // when a new backend lands.
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, tag query on Jaeger). When `errorsOnly=true`, only traces with at least one error span are returned.",
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
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",