@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
|
@@ -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,
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
|
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
|
|
549
|
-
"Behavior: read-only. `filter` accepts the backend's native query language (TraceQL on Tempo
|
|
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
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
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
|
|
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
|
-
|
|
79
|
+
raw_query?: string;
|
|
80
|
+
}, ctx?: RequestContext, opts?: {
|
|
81
|
+
allowRawQuery?: boolean;
|
|
82
|
+
}): Promise<{
|
|
80
83
|
content: {
|
|
81
84
|
type: "text";
|
|
82
85
|
text: string;
|