@thotischner/observability-mcp 1.7.0 → 1.7.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.
@@ -29,6 +29,7 @@ export declare class KubernetesConnector implements ObservabilityConnector {
29
29
  readonly signalType: SignalType;
30
30
  name: string;
31
31
  private store;
32
+ private warnedVocab;
32
33
  private factory?;
33
34
  private informers;
34
35
  private providerOverride?;
@@ -1,4 +1,5 @@
1
1
  import { TopologyStore, podResource, podEdges, nodeResource, deploymentResource, deploymentEdges, replicaSetResource, replicaSetEdges, namespaceResource, namespacedId, clusterScopedId, } from "./kubernetes-graph.js";
2
+ import { validateSnapshot } from "./topology-vocabulary.js";
2
3
  // Default provider is loaded lazily so tests don't pay the
3
4
  // @kubernetes/client-node import cost (and so the module is usable in
4
5
  // environments where the SDK isn't installed yet — e.g. unit tests in CI
@@ -19,6 +20,7 @@ export class KubernetesConnector {
19
20
  signalType = "topology";
20
21
  name = "";
21
22
  store;
23
+ warnedVocab = new Set();
22
24
  factory;
23
25
  informers = [];
24
26
  providerOverride;
@@ -141,12 +143,20 @@ export class KubernetesConnector {
141
143
  return this.store?.listEdges() ?? [];
142
144
  }
143
145
  async getTopologySnapshot() {
144
- return (this.store?.snapshot() ?? {
146
+ const snap = this.store?.snapshot() ?? {
145
147
  source: this.name,
146
148
  resources: [],
147
149
  edges: [],
148
150
  revision: 0,
149
- });
151
+ };
152
+ for (const w of validateSnapshot(snap.resources, snap.edges)) {
153
+ const key = `${w.kind}:${w.value}`;
154
+ if (this.warnedVocab.has(key))
155
+ continue;
156
+ this.warnedVocab.add(key);
157
+ console.warn("topology vocabulary warning (source=%s): %s", this.name, w.message);
158
+ }
159
+ return snap;
150
160
  }
151
161
  watchTopology(listener) {
152
162
  if (!this.store)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical vocabulary for the connector-agnostic topology graph.
3
+ *
4
+ * The set is intentionally small: only values that are emitted by a shipped
5
+ * connector OR are reserved as the agreed name for a near-term connector.
6
+ * Adding a value is a documentation change in docs/topology-vocabulary.md
7
+ * plus an entry in the const arrays below; never invent a value at the call
8
+ * site without that paper trail.
9
+ *
10
+ * Validation is warn-only by design — a connector that emits an unknown
11
+ * value still works, but the warning shows up in logs and unit tests so
12
+ * vocabulary drift gets caught before it spreads.
13
+ */
14
+ import type { Edge, Resource } from "../types.js";
15
+ export declare const KINDS: readonly ["pod", "node", "deployment", "replicaset", "namespace", "service", "container", "vm", "host", "hypervisor", "cluster"];
16
+ export type Kind = (typeof KINDS)[number];
17
+ export declare const RELATIONS: readonly ["RUNS_ON", "OWNED_BY", "IN_NAMESPACE", "CALLS", "CONTAINS", "DEPENDS_ON"];
18
+ export type Relation = (typeof RELATIONS)[number];
19
+ export declare function isKnownKind(k: string): k is Kind;
20
+ export declare function isKnownRelation(r: string): r is Relation;
21
+ export interface VocabularyWarning {
22
+ kind: "unknown_resource_kind" | "unknown_relation" | "case_mismatch";
23
+ message: string;
24
+ value: string;
25
+ }
26
+ /**
27
+ * Lint a single resource. Returns warnings for unknown or miscased `kind`
28
+ * values; an empty array means the resource passes the vocabulary.
29
+ */
30
+ export declare function validateResource(r: Pick<Resource, "kind">): VocabularyWarning[];
31
+ /**
32
+ * Lint a single edge. Returns warnings for unknown or miscased `relation`
33
+ * values; an empty array means the edge passes the vocabulary.
34
+ */
35
+ export declare function validateEdge(e: Pick<Edge, "relation">): VocabularyWarning[];
36
+ /**
37
+ * Convenience: lint a full snapshot. Returns the de-duplicated set of
38
+ * warnings (one per distinct value) so a noisy connector does not flood
39
+ * the log on each tick.
40
+ */
41
+ export declare function validateSnapshot(resources: Pick<Resource, "kind">[], edges: Pick<Edge, "relation">[]): VocabularyWarning[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Canonical vocabulary for the connector-agnostic topology graph.
3
+ *
4
+ * The set is intentionally small: only values that are emitted by a shipped
5
+ * connector OR are reserved as the agreed name for a near-term connector.
6
+ * Adding a value is a documentation change in docs/topology-vocabulary.md
7
+ * plus an entry in the const arrays below; never invent a value at the call
8
+ * site without that paper trail.
9
+ *
10
+ * Validation is warn-only by design — a connector that emits an unknown
11
+ * value still works, but the warning shows up in logs and unit tests so
12
+ * vocabulary drift gets caught before it spreads.
13
+ */
14
+ export const KINDS = [
15
+ "pod",
16
+ "node",
17
+ "deployment",
18
+ "replicaset",
19
+ "namespace",
20
+ "service",
21
+ "container",
22
+ "vm",
23
+ "host",
24
+ "hypervisor",
25
+ "cluster",
26
+ ];
27
+ export const RELATIONS = [
28
+ "RUNS_ON",
29
+ "OWNED_BY",
30
+ "IN_NAMESPACE",
31
+ "CALLS",
32
+ "CONTAINS",
33
+ "DEPENDS_ON",
34
+ ];
35
+ const KIND_SET = new Set(KINDS);
36
+ const RELATION_SET = new Set(RELATIONS);
37
+ export function isKnownKind(k) {
38
+ return KIND_SET.has(k);
39
+ }
40
+ export function isKnownRelation(r) {
41
+ return RELATION_SET.has(r);
42
+ }
43
+ /**
44
+ * Lint a single resource. Returns warnings for unknown or miscased `kind`
45
+ * values; an empty array means the resource passes the vocabulary.
46
+ */
47
+ export function validateResource(r) {
48
+ const out = [];
49
+ if (!isKnownKind(r.kind)) {
50
+ const lower = r.kind.toLowerCase();
51
+ if (isKnownKind(lower)) {
52
+ out.push({
53
+ kind: "case_mismatch",
54
+ value: r.kind,
55
+ message: `resource kind "${r.kind}" should be lowercase "${lower}" — see docs/topology-vocabulary.md`,
56
+ });
57
+ }
58
+ else {
59
+ out.push({
60
+ kind: "unknown_resource_kind",
61
+ value: r.kind,
62
+ message: `resource kind "${r.kind}" is not in the canonical vocabulary; either rename or extend docs/topology-vocabulary.md + KINDS`,
63
+ });
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Lint a single edge. Returns warnings for unknown or miscased `relation`
70
+ * values; an empty array means the edge passes the vocabulary.
71
+ */
72
+ export function validateEdge(e) {
73
+ const out = [];
74
+ if (!isKnownRelation(e.relation)) {
75
+ const upper = e.relation.toUpperCase();
76
+ if (isKnownRelation(upper)) {
77
+ out.push({
78
+ kind: "case_mismatch",
79
+ value: e.relation,
80
+ message: `relation "${e.relation}" should be UPPER_SNAKE "${upper}" — see docs/topology-vocabulary.md`,
81
+ });
82
+ }
83
+ else {
84
+ out.push({
85
+ kind: "unknown_relation",
86
+ value: e.relation,
87
+ message: `relation "${e.relation}" is not in the canonical vocabulary; either rename or extend docs/topology-vocabulary.md + RELATIONS`,
88
+ });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Convenience: lint a full snapshot. Returns the de-duplicated set of
95
+ * warnings (one per distinct value) so a noisy connector does not flood
96
+ * the log on each tick.
97
+ */
98
+ export function validateSnapshot(resources, edges) {
99
+ const seen = new Set();
100
+ const out = [];
101
+ for (const r of resources) {
102
+ for (const w of validateResource(r)) {
103
+ const key = `r:${w.kind}:${w.value}`;
104
+ if (!seen.has(key)) {
105
+ seen.add(key);
106
+ out.push(w);
107
+ }
108
+ }
109
+ }
110
+ for (const e of edges) {
111
+ for (const w of validateEdge(e)) {
112
+ const key = `e:${w.kind}:${w.value}`;
113
+ if (!seen.has(key)) {
114
+ seen.add(key);
115
+ out.push(w);
116
+ }
117
+ }
118
+ }
119
+ return out;
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { KINDS, RELATIONS, isKnownKind, isKnownRelation, validateResource, validateEdge, validateSnapshot, } from "./topology-vocabulary.js";
4
+ test("vocabulary — KINDS and RELATIONS contain what the kubernetes connector emits today", () => {
5
+ for (const k of ["pod", "node", "deployment", "replicaset", "namespace"]) {
6
+ assert.equal(isKnownKind(k), true, `kind "${k}" must be in the canonical vocabulary`);
7
+ }
8
+ for (const r of ["RUNS_ON", "OWNED_BY", "IN_NAMESPACE"]) {
9
+ assert.equal(isKnownRelation(r), true, `relation "${r}" must be in the canonical vocabulary`);
10
+ }
11
+ });
12
+ test("vocabulary — CALLS is reserved for the upcoming trace connector", () => {
13
+ assert.equal(isKnownRelation("CALLS"), true);
14
+ });
15
+ test("validateResource — canonical kinds produce no warnings", () => {
16
+ for (const k of KINDS) {
17
+ assert.deepEqual(validateResource({ kind: k }), []);
18
+ }
19
+ });
20
+ test("validateResource — unknown kind warns", () => {
21
+ const w = validateResource({ kind: "frobnicator" });
22
+ assert.equal(w.length, 1);
23
+ assert.equal(w[0].kind, "unknown_resource_kind");
24
+ assert.equal(w[0].value, "frobnicator");
25
+ });
26
+ test("validateResource — uppercase kind triggers a case-mismatch hint", () => {
27
+ const w = validateResource({ kind: "Pod" });
28
+ assert.equal(w.length, 1);
29
+ assert.equal(w[0].kind, "case_mismatch");
30
+ assert.match(w[0].message, /lowercase "pod"/);
31
+ });
32
+ test("validateEdge — canonical relations produce no warnings", () => {
33
+ for (const r of RELATIONS) {
34
+ assert.deepEqual(validateEdge({ relation: r }), []);
35
+ }
36
+ });
37
+ test("validateEdge — unknown relation warns", () => {
38
+ const w = validateEdge({ relation: "FROBNICATES" });
39
+ assert.equal(w.length, 1);
40
+ assert.equal(w[0].kind, "unknown_relation");
41
+ });
42
+ test("validateEdge — lowercase relation triggers a case-mismatch hint", () => {
43
+ const w = validateEdge({ relation: "runs_on" });
44
+ assert.equal(w.length, 1);
45
+ assert.equal(w[0].kind, "case_mismatch");
46
+ assert.match(w[0].message, /UPPER_SNAKE "RUNS_ON"/);
47
+ });
48
+ test("validateSnapshot — de-duplicates repeated offenders", () => {
49
+ const warnings = validateSnapshot([
50
+ { kind: "frobnicator" },
51
+ { kind: "frobnicator" },
52
+ { kind: "pod" },
53
+ ], [
54
+ { relation: "FROBNICATES" },
55
+ { relation: "FROBNICATES" },
56
+ { relation: "RUNS_ON" },
57
+ ]);
58
+ assert.equal(warnings.length, 2, `expected one warning per distinct offender, got ${warnings.length}`);
59
+ });
60
+ test("validateSnapshot — a fully canonical snapshot is silent", () => {
61
+ const warnings = validateSnapshot([{ kind: "pod" }, { kind: "node" }, { kind: "deployment" }], [{ relation: "RUNS_ON" }, { relation: "OWNED_BY" }, { relation: "IN_NAMESPACE" }]);
62
+ assert.deepEqual(warnings, []);
63
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.7.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",