@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
@@ -0,0 +1,85 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ipv4ToInt, parseCidr, IpEnrichmentDataset } from "./ip-dataset.js";
4
+ describe("ipv4ToInt", () => {
5
+ it("parses valid IPv4", () => {
6
+ assert.equal(ipv4ToInt("0.0.0.0"), 0);
7
+ assert.equal(ipv4ToInt("255.255.255.255"), 4294967295);
8
+ assert.equal(ipv4ToInt("1.2.3.4"), 0x01020304);
9
+ });
10
+ it("rejects malformed / out-of-range / non-IPv4", () => {
11
+ assert.equal(ipv4ToInt("1.2.3"), null);
12
+ assert.equal(ipv4ToInt("1.2.3.256"), null);
13
+ assert.equal(ipv4ToInt("1.2.3.4.5"), null);
14
+ assert.equal(ipv4ToInt("a.b.c.d"), null);
15
+ assert.equal(ipv4ToInt("::1"), null);
16
+ assert.equal(ipv4ToInt(""), null);
17
+ });
18
+ });
19
+ describe("parseCidr", () => {
20
+ it("parses a /24 to its inclusive range", () => {
21
+ const r = parseCidr("1.2.3.0/24");
22
+ assert.deepEqual(r, { start: 0x01020300, end: 0x010203ff, prefix: 24 });
23
+ });
24
+ it("treats a bare IP as /32", () => {
25
+ const r = parseCidr("203.0.113.5");
26
+ assert.equal(r?.prefix, 32);
27
+ assert.equal(r?.start, r?.end);
28
+ });
29
+ it("normalises a non-aligned base to the network address", () => {
30
+ // 1.2.3.42/24 → network 1.2.3.0
31
+ const r = parseCidr("1.2.3.42/24");
32
+ assert.equal(r?.start, 0x01020300);
33
+ });
34
+ it("handles /0 (whole space)", () => {
35
+ const r = parseCidr("0.0.0.0/0");
36
+ assert.deepEqual(r, { start: 0, end: 4294967295, prefix: 0 });
37
+ });
38
+ it("rejects bad prefixes / addresses", () => {
39
+ assert.equal(parseCidr("1.2.3.0/33"), null);
40
+ assert.equal(parseCidr("1.2.3.0/-1"), null);
41
+ assert.equal(parseCidr("nope/24"), null);
42
+ });
43
+ });
44
+ describe("IpEnrichmentDataset.fromCsv + lookup", () => {
45
+ const csv = [
46
+ "network,country,city,asn,org,hosting", // header skipped
47
+ "# a comment line",
48
+ "",
49
+ "10.0.0.0/8,US,,AS100,Example Cloud,true",
50
+ "10.1.2.0/24,US,Ashburn,AS100,Example Cloud Edge,true",
51
+ "203.0.113.5,DE,Berlin,AS3320,Example ISP,false",
52
+ "2001:db8::/32,XX,,,,", // IPv6 → skipped
53
+ "garbage-row",
54
+ ].join("\n");
55
+ it("parses rows, skips header/comments/blank, counts skipped", () => {
56
+ const ds = IpEnrichmentDataset.fromCsv(csv);
57
+ assert.equal(ds.size, 3); // 3 valid IPv4 rows
58
+ assert.equal(ds.skipped, 2); // IPv6 + garbage
59
+ });
60
+ it("returns the most specific (longest-prefix) match", () => {
61
+ const ds = IpEnrichmentDataset.fromCsv(csv);
62
+ // 10.1.2.5 is inside both /8 and /24 → the /24 (more specific) wins.
63
+ const hit = ds.lookup("10.1.2.5");
64
+ assert.equal(hit?.city, "Ashburn");
65
+ assert.equal(hit?.org, "Example Cloud Edge");
66
+ assert.equal(hit?.hosting, true);
67
+ });
68
+ it("falls back to the broader range when no specific one matches", () => {
69
+ const ds = IpEnrichmentDataset.fromCsv(csv);
70
+ const hit = ds.lookup("10.5.5.5"); // only in /8
71
+ assert.equal(hit?.asn, "AS100");
72
+ assert.equal(hit?.city, undefined); // empty cell omitted
73
+ });
74
+ it("matches a /32 exactly and parses hosting=false", () => {
75
+ const ds = IpEnrichmentDataset.fromCsv(csv);
76
+ const hit = ds.lookup("203.0.113.5");
77
+ assert.equal(hit?.country, "DE");
78
+ assert.equal(hit?.hosting, false);
79
+ });
80
+ it("returns null for an unmatched or invalid IP", () => {
81
+ const ds = IpEnrichmentDataset.fromCsv(csv);
82
+ assert.equal(ds.lookup("8.8.8.8"), null);
83
+ assert.equal(ds.lookup("not-an-ip"), null);
84
+ });
85
+ });
package/dist/index.js CHANGED
@@ -58,6 +58,8 @@ import { listSourcesHandler } from "./tools/list-sources.js";
58
58
  import { listServicesHandler } from "./tools/list-services.js";
59
59
  import { queryMetricsHandler } from "./tools/query-metrics.js";
60
60
  import { queryLogsHandler } from "./tools/query-logs.js";
61
+ import { enrichIpsHandler } from "./tools/enrich-ips.js";
62
+ import { IpEnrichmentDataset } from "./enrich/ip-dataset.js";
61
63
  import { queryTracesHandler } from "./tools/query-traces.js";
62
64
  import { getAnomalyHistoryHandler } from "./tools/get-anomaly-history.js";
63
65
  import { generatePostmortemHandler } from "./tools/generate-postmortem.js";
@@ -269,6 +271,33 @@ async function main() {
269
271
  return applyBudgetDecision(result, decision, tokens, toolName);
270
272
  }
271
273
  const REDACTION_ENABLED = String(process.env.OMCP_REDACTION ?? "on").toLowerCase() !== "off";
274
+ // Raw PromQL/LogQL passthrough capability — default OFF. A raw query bypasses
275
+ // the curated metric/log surface (catalog, selector scoping), so it is an
276
+ // explicit operator opt-in. Enable with OMCP_RAW_QUERY=on (or true/1).
277
+ const RAW_QUERY_ENABLED = ["on", "true", "1"].includes(String(process.env.OMCP_RAW_QUERY ?? "off").toLowerCase());
278
+ // Opt the anonymous/default identity into per-call redaction bypass. In an
279
+ // anonymous deployment (no OMCP_API_KEYS) there is no named credential to
280
+ // add to OMCP_KEY_BYPASS_REDACTION, so a per-call bypass_redaction can never
281
+ // succeed — the only lever was the blunt global OMCP_REDACTION=off. This
282
+ // flag lets a single-user self-hosted agent see raw values on its own logs
283
+ // via the per-call arg, while redaction stays the default. Default OFF.
284
+ const BYPASS_REDACTION_ANON = ["on", "true", "1"].includes(String(process.env.OMCP_BYPASS_REDACTION_ANON ?? "false").toLowerCase());
285
+ // Offline IP-enrichment dataset (issue #415 Gap B) — loaded once at boot from
286
+ // a local CSV (OMCP_IP_ENRICH_FILE). No external geo/ASN API is ever called,
287
+ // so it stays air-gapped. Unset / unreadable → enrich_ips returns a clear
288
+ // "not configured" notice rather than failing.
289
+ let ipEnrichment = null;
290
+ const ipEnrichFile = process.env.OMCP_IP_ENRICH_FILE;
291
+ if (ipEnrichFile) {
292
+ try {
293
+ ipEnrichment = IpEnrichmentDataset.fromCsv(readFileSync(ipEnrichFile, "utf8"));
294
+ console.log(`[enrich] IP enrichment dataset loaded from ${ipEnrichFile}: ${ipEnrichment.size} ranges` +
295
+ (ipEnrichment.skipped ? ` (${ipEnrichment.skipped} rows skipped)` : ""));
296
+ }
297
+ catch (err) {
298
+ console.error(`[enrich] failed to load OMCP_IP_ENRICH_FILE (${ipEnrichFile}): ${err instanceof Error ? err.message : String(err)} — enrich_ips will report 'not configured'`);
299
+ }
300
+ }
272
301
  function redactToolText(result, opts = {}) {
273
302
  if (!REDACTION_ENABLED)
274
303
  return result;
@@ -370,7 +399,7 @@ async function main() {
370
399
  registerTool("list_sources", [
371
400
  "List the configured observability backends (Prometheus, Loki, and any connector) and whether each is currently reachable.",
372
401
  "When to use: call this first to learn which source names exist and are healthy before passing `source` to other tools, or to debug why a query returns no data.",
373
- "Behavior: read-only, no side effects. Returns one entry per source with its name, type, configured URL, signal types (metrics/logs), and a live up/down status. Never throws for an unreachable backend — the backend is reported as down instead.",
402
+ "Behavior: read-only, no side effects. Returns one entry per source with its name, type, signal types (metrics/logs), and a live up/down status (the backend URL is intentionally not exposed — it may carry embedded credentials). Never throws for an unreachable backend — the backend is reported as down instead.",
374
403
  "Related: use `list_services` to see what is monitored within these sources.",
375
404
  ].join(" "), {}, async () => {
376
405
  await enforceEntitledAccess(ctx, { tool: "list_sources" });
@@ -403,10 +432,12 @@ async function main() {
403
432
  ].join(" "), {
404
433
  service: z
405
434
  .string()
406
- .describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'api-gateway', 'payment-service')."),
435
+ .optional()
436
+ .describe("Required (unless `raw_query` is set). Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'api-gateway', 'payment-service')."),
407
437
  metric: z
408
438
  .string()
409
- .describe(`Required. Exact metric name to query. One of: ${uniqueNames.join(", ")}.`),
439
+ .optional()
440
+ .describe(`Required (unless ` + "`raw_query`" + ` is set). Exact metric name to query. One of: ${uniqueNames.join(", ")}.`),
410
441
  duration: z
411
442
  .string()
412
443
  .optional()
@@ -419,9 +450,17 @@ async function main() {
419
450
  .string()
420
451
  .optional()
421
452
  .describe("Optional. Metric label to break the result down by, e.g. 'instance', 'pod', 'node'. When set, the response contains one series per distinct label value under `groups`. Default: a single aggregated series."),
453
+ labels: z
454
+ .record(z.string(), z.string())
455
+ .optional()
456
+ .describe("Optional. Exact-match label filters (e.g. {\"status\":\"500\",\"route\":\"/checkout\"}) AND'd into the metric's series selector — the PromQL equivalent of the query_logs `labels` param. Use this to scope a curated metric to a subset of series (e.g. error_rate for one route/status) instead of the all-series aggregate. Combine with `groupBy` to filter then break down. Label names must be valid Prometheus identifiers."),
457
+ raw_query: z
458
+ .string()
459
+ .optional()
460
+ .describe("Optional escape hatch: a verbatim PromQL expression, run as-is over the range — for ad-hoc queries the curated `metric` catalog can't express (any series, any function, broken down by any label). When set, `metric`/`service`/`groupBy`/`labels` are ignored. DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Still tenant-scoped and source-allow-listed."),
422
461
  }, async (args) => {
423
462
  await enforceEntitledAccess(ctx, { tool: "query_metrics", source: args?.source, service: args?.service });
424
- const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx));
463
+ const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
425
464
  return chargeTokenBudget(result, ctx, "query_metrics");
426
465
  });
427
466
  registerTool("query_logs", [
@@ -432,7 +471,8 @@ async function main() {
432
471
  ].join(" "), {
433
472
  service: z
434
473
  .string()
435
- .describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'payment-service')."),
474
+ .optional()
475
+ .describe("Required (unless `raw_query` is set). Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'payment-service')."),
436
476
  query: z
437
477
  .string()
438
478
  .optional()
@@ -480,10 +520,14 @@ async function main() {
480
520
  bypass_redaction: z
481
521
  .boolean()
482
522
  .optional()
483
- .describe("Optional. When true, request that PII/secret redaction be skipped for this single call. The server only honours this when the calling credential was explicitly authorised via OMCP_KEY_BYPASS_REDACTION; otherwise the request still gets redacted output. Default: false."),
523
+ .describe("Optional. When true, request that PII/secret redaction be skipped for this single call. The server only honours this when the calling identity is authorised to bypass — a credential listed in OMCP_KEY_BYPASS_REDACTION, or the anonymous identity when the operator set OMCP_BYPASS_REDACTION_ANON=true; otherwise the request still gets redacted output. Default: false."),
524
+ raw_query: z
525
+ .string()
526
+ .optional()
527
+ .describe("Optional escape hatch: a verbatim LogQL log query, run as-is — for selectors/pipelines the curated params can't express. When set, `service`/`labels`/`level`/`query` are ignored and it is mutually exclusive with `aggregate` (express aggregation in the LogQL itself). DISABLED by default; the operator must enable the raw-query capability (OMCP_RAW_QUERY=on) or the call is refused. Redaction still applies to the returned log lines."),
484
528
  }, async (args) => {
485
529
  await enforceEntitledAccess(ctx, { tool: "query_logs", source: args?.source, service: args?.service });
486
- const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx));
530
+ const result = await withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx, { allowRawQuery: RAW_QUERY_ENABLED }));
487
531
  // Redact PII / secrets from the log payload before it crosses the
488
532
  // MCP boundary into the agent's context. Per-call bypass kicks in
489
533
  // only when BOTH (a) the credential is OMCP_KEY_BYPASS_REDACTION
@@ -545,8 +589,8 @@ async function main() {
545
589
  "Query distributed traces for a service over a given timeframe.",
546
590
  "Returns ranked trace summaries (duration, span count, error status) with a p50/p95 aggregate across the returned set.",
547
591
  "When to use: investigate tail-latency outliers, walk call chains across services for a specific time window, or pull traces related to an anomaly that the metric/log tools surfaced first.",
548
- "Prerequisites: get the exact service name from `list_services`. A Tempo / Jaeger / OTLP connector must be configured.",
549
- "Behavior: read-only. `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. Default limit is 50.",
592
+ "Prerequisites: get the exact service name from `list_services`. A traces connector (e.g. Tempo, installable from the connector hub) must be configured — none is bundled by default, so without one this returns a clean 'No trace backends configured' result.",
593
+ "Behavior: read-only. `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. Default limit is 50.",
550
594
  ].join(" "), {
551
595
  service: z.string().describe("Service name (e.g. 'payment-service')."),
552
596
  duration: z.string().optional().describe("Rolling time window, e.g. '5m', '1h'. Default '15m'."),
@@ -639,6 +683,19 @@ async function main() {
639
683
  await enforceEntitledAccess(ctx, { tool: "get_blast_radius" });
640
684
  return withToolMetrics("get_blast_radius", () => getBlastRadiusHandler(registry, args, ctx));
641
685
  });
686
+ registerTool("enrich_ips", [
687
+ "Resolve a batch of IPv4 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
688
+ "When to use: answering 'where are these visitors from?' or 'which of these IPs are bots / datacenter / VPN exit nodes?' over access logs, without an out-of-band geo-API call per IP.",
689
+ "Behavior: read-only. Looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) — there is no external network call, so it is safe in air-gapped deployments. Returns one row per input IP with found=true/false plus any known fields. If no dataset is configured it returns a clear notice explaining how to enable it.",
690
+ "Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
691
+ ].join(" "), {
692
+ ips: z
693
+ .array(z.string())
694
+ .describe("Required. IPv4 address strings to enrich (e.g. ['203.0.113.5','198.51.100.9']). Max 1000 per call; invalid entries are returned with found=false rather than failing the batch."),
695
+ }, async (args) => {
696
+ await enforceEntitledAccess(ctx, { tool: "enrich_ips" });
697
+ return withToolMetrics("enrich_ips", async () => enrichIpsHandler(ipEnrichment, args, ctx));
698
+ });
642
699
  // Phase F10: federated tools — every upstream MCP server's tools
643
700
  // show up here under `<prefix>.<upstream-tool>`. The handler is a
644
701
  // pure proxy: it forwards args verbatim and returns the upstream's
@@ -1060,11 +1117,11 @@ async function main() {
1060
1117
  // get_anomaly_history queries them back via any Prometheus source
1061
1118
  // pointed at the same TSDB.
1062
1119
  //
1063
- // The detector-side hook that actually records per-anomaly scores
1064
- // is plumbed in F15b (it requires passing this instance into the
1065
- // detectAnomaliesHandler minor surgery deferred). The
1066
- // infrastructure ships now so externally-written omcp_anomaly_score
1067
- // metrics are already queryable end-to-end.
1120
+ // The detector-side hook that records per-anomaly scores is wired:
1121
+ // this instance is passed into detectAnomaliesHandler at the
1122
+ // detect_anomalies tool registration below, so every scan records its
1123
+ // scores. Externally-written omcp_anomaly_score metrics are queryable
1124
+ // end-to-end too.
1068
1125
  const anomalyHistory = new AnomalyHistory(anomalyHistoryFromEnv());
1069
1126
  anomalyHistory.start();
1070
1127
  if (anomalyHistory.isEnabled()) {
@@ -3030,8 +3087,10 @@ async function main() {
3030
3087
  res.json({ ok: true });
3031
3088
  });
3032
3089
  // Stdio transport: one server over stdin/stdout, no HTTP listener.
3090
+ // Stdio is inherently a local single-user channel, so the anonymous
3091
+ // redaction-bypass opt-in applies here too.
3033
3092
  if (STDIO) {
3034
- const { mcpServer: server } = createMcpServer(defaultContext());
3093
+ const { mcpServer: server } = createMcpServer(defaultContext({ allowBypassRedaction: BYPASS_REDACTION_ANON }));
3035
3094
  await server.connect(new StdioServerTransport());
3036
3095
  console.error(`observability-mcp running on stdio transport · connectors: ${registry
3037
3096
  .getAll()
@@ -3127,7 +3186,7 @@ async function main() {
3127
3186
  // coarse source allow-list into the RequestContext.
3128
3187
  async function gateCtx(req, res) {
3129
3188
  if (!credentialsConfigured())
3130
- return defaultContext();
3189
+ return defaultContext({ allowBypassRedaction: BYPASS_REDACTION_ANON });
3131
3190
  const cred = resolveToken(extractToken(req.headers), loadCredentials());
3132
3191
  if (!cred) {
3133
3192
  res
@@ -18,8 +18,14 @@ export declare const manifestSchema: z.ZodObject<{
18
18
  capabilities: z.ZodOptional<z.ZodObject<{
19
19
  queryMetrics: z.ZodOptional<z.ZodBoolean>;
20
20
  queryLogs: z.ZodOptional<z.ZodBoolean>;
21
+ queryLogAggregate: z.ZodOptional<z.ZodBoolean>;
22
+ queryTraces: z.ZodOptional<z.ZodBoolean>;
21
23
  listServices: z.ZodOptional<z.ZodBoolean>;
22
24
  listAvailableMetrics: z.ZodOptional<z.ZodBoolean>;
25
+ listResources: z.ZodOptional<z.ZodBoolean>;
26
+ listEdges: z.ZodOptional<z.ZodBoolean>;
27
+ getTopologySnapshot: z.ZodOptional<z.ZodBoolean>;
28
+ watchTopology: z.ZodOptional<z.ZodBoolean>;
23
29
  }, z.core.$strip>>;
24
30
  compat: z.ZodOptional<z.ZodObject<{
25
31
  serverVersion: z.ZodOptional<z.ZodString>;
@@ -24,8 +24,15 @@ export const manifestSchema = z.object({
24
24
  .object({
25
25
  queryMetrics: z.boolean().optional(),
26
26
  queryLogs: z.boolean().optional(),
27
+ queryLogAggregate: z.boolean().optional(),
28
+ queryTraces: z.boolean().optional(),
27
29
  listServices: z.boolean().optional(),
28
30
  listAvailableMetrics: z.boolean().optional(),
31
+ // Topology-provider capabilities (e.g. the Kubernetes connector).
32
+ listResources: z.boolean().optional(),
33
+ listEdges: z.boolean().optional(),
34
+ getTopologySnapshot: z.boolean().optional(),
35
+ watchTopology: z.boolean().optional(),
29
36
  })
30
37
  .optional(),
31
38
  compat: z
@@ -0,0 +1,30 @@
1
+ import { IpEnrichmentDataset } from "../enrich/ip-dataset.js";
2
+ import { type RequestContext } from "../context.js";
3
+ export declare const enrichIpsDefinition: {
4
+ name: "enrich_ips";
5
+ description: string;
6
+ };
7
+ export interface EnrichIpsArgs {
8
+ ips?: string[];
9
+ }
10
+ export interface IpEnrichmentResult {
11
+ ip: string;
12
+ found: boolean;
13
+ country?: string;
14
+ city?: string;
15
+ asn?: string;
16
+ org?: string;
17
+ hosting?: boolean;
18
+ }
19
+ export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext): {
20
+ content: {
21
+ type: "text";
22
+ text: string;
23
+ }[];
24
+ isError: boolean;
25
+ } | {
26
+ content: {
27
+ type: "text";
28
+ text: string;
29
+ }[];
30
+ };
@@ -0,0 +1,60 @@
1
+ import { ipv4ToInt } from "../enrich/ip-dataset.js";
2
+ import { defaultContext } from "../context.js";
3
+ import { errorResponse } from "./validation.js";
4
+ // enrich_ips (issue #415 Gap B): resolve a batch of IPs to geo / ASN / org /
5
+ // hosting-flag from the operator's LOCAL offline dataset. No external lookups,
6
+ // so it is safe in air-gapped deployments. Disabled (returns a clear message)
7
+ // when no dataset is configured.
8
+ export const enrichIpsDefinition = {
9
+ name: "enrich_ips",
10
+ description: "Resolve a batch of IPv4 addresses to geo (country/city), ASN/org, and a hosting/proxy flag from a local offline dataset. Use this to answer 'where are these visitors from / which are bots or datacenter IPs' without an out-of-band geo API call. Requires the operator to have configured an offline dataset (OMCP_IP_ENRICH_FILE); returns a clear notice otherwise.",
11
+ };
12
+ const MAX_IPS = 1000;
13
+ export function enrichIpsHandler(dataset, args,
14
+ // The RequestContext seam — enrich_ips doesn't scope by tenant today (the
15
+ // dataset is a single process-wide table), but every tool handler threads
16
+ // ctx so access-control / audit can attach without a signature change later.
17
+ _ctx = defaultContext()) {
18
+ if (!dataset) {
19
+ return errorResponse("IP enrichment is not configured. Set OMCP_IP_ENRICH_FILE to a local CSV " +
20
+ "(network,country,city,asn,org,hosting) to enable offline geo/ASN/hosting " +
21
+ "lookups — there is no external API call, so it stays air-gapped.");
22
+ }
23
+ const ips = args.ips;
24
+ if (!Array.isArray(ips) || ips.length === 0) {
25
+ return errorResponse("`ips` must be a non-empty array of IPv4 address strings.");
26
+ }
27
+ if (ips.length > MAX_IPS) {
28
+ return errorResponse(`Too many IPs (${ips.length}); max ${MAX_IPS} per call.`);
29
+ }
30
+ const results = [];
31
+ let invalid = 0;
32
+ let matched = 0;
33
+ for (const ip of ips) {
34
+ if (typeof ip !== "string" || ipv4ToInt(ip) === null) {
35
+ invalid++;
36
+ results.push({ ip: String(ip), found: false });
37
+ continue;
38
+ }
39
+ const hit = dataset.lookup(ip);
40
+ if (hit) {
41
+ matched++;
42
+ results.push({ ip, found: true, ...hit });
43
+ }
44
+ else {
45
+ results.push({ ip, found: false });
46
+ }
47
+ }
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: JSON.stringify({
53
+ results,
54
+ summary: { total: ips.length, matched, unmatched: ips.length - matched - invalid, invalid },
55
+ datasetSize: dataset.size,
56
+ }, null, 2),
57
+ },
58
+ ],
59
+ };
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { IpEnrichmentDataset } from "../enrich/ip-dataset.js";
4
+ import { enrichIpsHandler } from "./enrich-ips.js";
5
+ function parse(result) {
6
+ return JSON.parse(result.content[0].text);
7
+ }
8
+ const ds = IpEnrichmentDataset.fromCsv(["1.2.3.0/24,US,Ashburn,AS14618,Example Cloud,true", "203.0.113.5,DE,Berlin,AS3320,Example ISP,false"].join("\n"));
9
+ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
10
+ it("returns a clear 'not configured' notice when no dataset is loaded", () => {
11
+ const out = parse(enrichIpsHandler(null, { ips: ["1.2.3.4"] }));
12
+ assert.match(out.error, /not configured/i);
13
+ assert.match(out.error, /OMCP_IP_ENRICH_FILE/);
14
+ });
15
+ it("rejects empty / missing ips", () => {
16
+ assert.match(parse(enrichIpsHandler(ds, { ips: [] })).error, /non-empty array/i);
17
+ assert.match(parse(enrichIpsHandler(ds, {})).error, /non-empty array/i);
18
+ });
19
+ it("rejects an over-large batch", () => {
20
+ const many = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 255}`);
21
+ assert.match(parse(enrichIpsHandler(ds, { ips: many })).error, /Too many IPs/i);
22
+ });
23
+ it("enriches known IPs and reports found=false for misses + invalid", () => {
24
+ const out = parse(enrichIpsHandler(ds, { ips: ["1.2.3.99", "8.8.8.8", "not-an-ip"] }));
25
+ assert.equal(out.results.length, 3);
26
+ const matched = out.results.find((r) => r.ip === "1.2.3.99");
27
+ assert.equal(matched.found, true);
28
+ assert.equal(matched.city, "Ashburn");
29
+ assert.equal(matched.hosting, true);
30
+ const miss = out.results.find((r) => r.ip === "8.8.8.8");
31
+ assert.equal(miss.found, false);
32
+ assert.equal(miss.city, undefined);
33
+ const invalid = out.results.find((r) => r.ip === "not-an-ip");
34
+ assert.equal(invalid.found, false);
35
+ assert.deepEqual(out.summary, { total: 3, matched: 1, unmatched: 1, invalid: 1 });
36
+ assert.equal(out.datasetSize, 2);
37
+ });
38
+ });
@@ -67,13 +67,20 @@ export async function getAnomalyHistoryHandler(registry, args, ctx = defaultCont
67
67
  labelFilters.push(`method="${escLabel(args.method)}"`);
68
68
  const metric = `omcp_anomaly_score{${labelFilters.join(",")}}`;
69
69
  // Fan out across every metrics connector; first non-empty answer wins.
70
+ // CRITICAL: pass the hand-built selector via `rawQuery`, NOT `metric`.
71
+ // The connector's curated path wraps a bare `metric` in `{ {{selector}} }`,
72
+ // which for our already-complete selector produces invalid double-brace
73
+ // PromQL (`omcp_anomaly_score{service="x"}{ job="x" }`) → 400 → the catch
74
+ // below swallowed it and the tool always reported "no history". rawQuery is
75
+ // sent verbatim to /api/v1/query_range (the R4 passthrough).
70
76
  for (const c of candidates) {
71
77
  if (!c.queryMetrics)
72
78
  continue;
73
79
  try {
74
80
  const r = await c.queryMetrics({
75
81
  service: args.service,
76
- metric,
82
+ metric: "omcp_anomaly_score",
83
+ rawQuery: metric,
77
84
  duration,
78
85
  });
79
86
  if (r && Array.isArray(r.values) && r.values.length > 0) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getAnomalyHistoryHandler } from "./get-anomaly-history.js";
4
+ // Regression guard for the wired-but-dead bug found in the v3.2 audit:
5
+ // get_anomaly_history hand-builds a complete PromQL selector
6
+ // (`omcp_anomaly_score{service="x",method="mad"}`) and must pass it via
7
+ // `rawQuery` (verbatim passthrough), NOT via `metric`. The curated `metric`
8
+ // path wraps the value in `{ {{selector}} }`, which for an already-complete
9
+ // selector yields invalid double-brace PromQL → Prometheus 400 → the handler
10
+ // swallowed it and always returned "no history". This test pins that the
11
+ // connector receives a verbatim rawQuery and never the manglable metric path.
12
+ function fakeRegistry(capture, result) {
13
+ const conn = {
14
+ name: "prom",
15
+ type: "prometheus",
16
+ signalType: "metrics",
17
+ async queryMetrics(q) {
18
+ capture(q);
19
+ if (!result)
20
+ throw new Error("no data");
21
+ return result;
22
+ },
23
+ };
24
+ return { getByTenant: () => [conn] };
25
+ }
26
+ function parse(r) {
27
+ return JSON.parse(r.content[0].text);
28
+ }
29
+ describe("get_anomaly_history — rawQuery wiring (audit regression)", () => {
30
+ it("routes the omcp_anomaly_score selector via rawQuery, not metric", async () => {
31
+ let captured;
32
+ const reg = fakeRegistry((q) => (captured = q), {
33
+ source: "prom",
34
+ service: "payment",
35
+ metric: "omcp_anomaly_score",
36
+ unit: "",
37
+ values: [{ timestamp: "2026-06-09T00:00:00.000Z", value: 0.7 }],
38
+ summary: { current: 0.7, average: 0.7, min: 0.7, max: 0.7, trend: "stable" },
39
+ });
40
+ const out = parse(await getAnomalyHistoryHandler(reg, { service: "payment", method: "mad", duration: "1h" }));
41
+ assert.ok(captured, "connector.queryMetrics must be called");
42
+ // The fix: rawQuery carries the verbatim selector.
43
+ assert.equal(captured.rawQuery, 'omcp_anomaly_score{service="payment",method="mad"}');
44
+ // And it must NOT be smuggled through the curated `metric` path (which would
45
+ // double-brace it). metric may be a bare name placeholder, but never the selector.
46
+ assert.doesNotMatch(String(captured.metric ?? ""), /\{/, "metric must not carry the brace selector");
47
+ // Sanity: the verbatim query has exactly one brace block (no double-brace).
48
+ assert.equal((captured.rawQuery.match(/\{/g) || []).length, 1);
49
+ assert.equal(out.isError, undefined);
50
+ assert.equal(out.values.length, 1);
51
+ });
52
+ it("omits the method filter when not given", async () => {
53
+ let captured;
54
+ const reg = fakeRegistry((q) => (captured = q), {
55
+ source: "prom", service: "api", metric: "omcp_anomaly_score", unit: "",
56
+ values: [{ timestamp: "2026-06-09T00:00:00.000Z", value: 1 }],
57
+ summary: { current: 1, average: 1, min: 1, max: 1, trend: "stable" },
58
+ });
59
+ await getAnomalyHistoryHandler(reg, { service: "api" });
60
+ assert.equal(captured.rawQuery, 'omcp_anomaly_score{service="api"}');
61
+ });
62
+ });
@@ -90,6 +90,21 @@ describe("listServicesHandler", () => {
90
90
  assert.deepEqual(apiGw.sources.sort(), ["loki1", "prom1"]);
91
91
  assert.deepEqual(apiGw.signalTypes.sort(), ["logs", "metrics"]);
92
92
  });
93
+ it("carries per-service labels (e.g. discoveredVia) through the merge (audit: docs/loki.md)", async () => {
94
+ const reg = createRegistryWithMocks([
95
+ createMockConnector({
96
+ name: "loki1", type: "loki", signalType: "logs",
97
+ listServices: async () => [
98
+ { name: "payment-service", source: "loki1", signalType: "logs", labels: { discoveredVia: "service_name" } },
99
+ ],
100
+ }),
101
+ ]);
102
+ const result = await listServicesHandler(reg, {});
103
+ const data = JSON.parse(result.content[0].text);
104
+ const svc = data.services.find((s) => s.name === "payment-service");
105
+ assert.ok(svc, "service must be present");
106
+ assert.equal(svc.labels?.discoveredVia, "service_name", "discoveredVia must surface in the tool output");
107
+ });
93
108
  it("filters services case-insensitively", async () => {
94
109
  const reg = createRegistryWithMocks([
95
110
  createMockConnector({
@@ -25,7 +25,10 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
25
25
  console.error(`Failed to list services from ${connector.name}:`, err);
26
26
  }
27
27
  }
28
- // Deduplicate by name, merge signal types
28
+ // Deduplicate by name, merge signal types. Carry per-service `labels`
29
+ // (e.g. the Loki connector's `discoveredVia`, documented in docs/loki.md)
30
+ // through the merge so discovery metadata actually surfaces in the tool
31
+ // output; first source to set a given label key wins.
29
32
  const merged = new Map();
30
33
  for (const svc of allServices) {
31
34
  const existing = merged.get(svc.name);
@@ -34,12 +37,15 @@ export async function listServicesHandler(registry, args, ctx = defaultContext()
34
37
  existing.sources.push(svc.source);
35
38
  if (!existing.signalTypes.includes(svc.signalType))
36
39
  existing.signalTypes.push(svc.signalType);
40
+ if (svc.labels)
41
+ existing.labels = { ...svc.labels, ...(existing.labels ?? {}) };
37
42
  }
38
43
  else {
39
44
  merged.set(svc.name, {
40
45
  name: svc.name,
41
46
  sources: [svc.source],
42
47
  signalTypes: [svc.signalType],
48
+ labels: svc.labels ? { ...svc.labels } : undefined,
43
49
  });
44
50
  }
45
51
  }
@@ -64,7 +64,7 @@ export declare const queryLogsDefinition: {
64
64
  };
65
65
  };
66
66
  export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
67
- service: string;
67
+ service?: string;
68
68
  query?: string;
69
69
  duration?: string;
70
70
  level?: string;
@@ -76,7 +76,10 @@ export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
76
76
  k?: number;
77
77
  step?: string;
78
78
  };
79
- }, ctx?: RequestContext): Promise<{
79
+ raw_query?: string;
80
+ }, ctx?: RequestContext, opts?: {
81
+ allowRawQuery?: boolean;
82
+ }): Promise<{
80
83
  content: {
81
84
  type: "text";
82
85
  text: string;