@thotischner/observability-mcp 1.8.1 → 3.0.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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -1,4 +1,4 @@
1
- import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
1
+ import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
2
2
  export interface ObservabilityConnector {
3
3
  readonly name: string;
4
4
  readonly type: string;
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
14
14
  listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
15
15
  queryMetrics?(params: MetricQuery): Promise<MetricResult>;
16
16
  queryLogs?(params: LogQuery): Promise<LogResult>;
17
+ /** Optional traces capability — Tempo / Jaeger / OTLP backends
18
+ * implement this. The MCP `query_traces` tool fans out to every
19
+ * connector that has it. */
20
+ queryTraces?(params: TraceQuery): Promise<TraceResult>;
17
21
  /** Current in-memory resource list. Should be O(1) — backed by the watch cache. */
18
22
  listResources?(): Promise<Resource[]>;
19
23
  /** Current in-memory edge list. Should be O(1) — backed by the watch cache. */
@@ -1,5 +1,6 @@
1
1
  import type { ObservabilityConnector } from "./interface.js";
2
2
  import type { ConnectorFactory, ConnectorManifest } from "../sdk/index.js";
3
+ import type { HookRegistry } from "../sdk/hooks.js";
3
4
  export interface LoadedConnector {
4
5
  /** Connector type id, e.g. "prometheus". Matches `source.type` in sources.yaml. */
5
6
  name: string;
@@ -26,11 +27,18 @@ export declare class PluginLoader {
26
27
  private verify;
27
28
  private trustRootPath?;
28
29
  private trustRoot?;
30
+ /** Optional HookRegistry — when set, the loader auto-registers
31
+ * every entry in `manifest.hooks[]` after the plugin loads, and
32
+ * unregisters them when a same-name plugin replaces it. Hooks
33
+ * re-registered by name+kind on hot-reload (HookRegistry.register
34
+ * already deduplicates). */
35
+ private hookRegistry?;
29
36
  constructor(opts?: {
30
37
  pluginsDir?: string;
31
38
  disabled?: string[];
32
39
  verify?: boolean;
33
40
  trustRoot?: string;
41
+ hookRegistry?: HookRegistry;
34
42
  });
35
43
  load(): Promise<void>;
36
44
  list(): LoadedConnector[];
@@ -23,23 +23,32 @@ export class PluginLoader {
23
23
  pluginsDir;
24
24
  disabled;
25
25
  // Fail-closed verification for filesystem plugins. Builtins are part
26
- // of the trusted image and are never gated. Default off so existing
27
- // deployments are unchanged; recommended on in prod/airgapped (the
28
- // Helm chart sets it).
26
+ // of the trusted image and are never gated. Default ON operators
27
+ // who want to load unsigned filesystem plugins must opt out with
28
+ // VERIFY_PLUGINS=false. Without a trust root configured, no
29
+ // filesystem plugins load (only builtins), so the demo and any
30
+ // deployment without /app/plugins is unaffected.
29
31
  verify;
30
32
  trustRootPath;
31
33
  trustRoot;
34
+ /** Optional HookRegistry — when set, the loader auto-registers
35
+ * every entry in `manifest.hooks[]` after the plugin loads, and
36
+ * unregisters them when a same-name plugin replaces it. Hooks
37
+ * re-registered by name+kind on hot-reload (HookRegistry.register
38
+ * already deduplicates). */
39
+ hookRegistry;
32
40
  constructor(opts = {}) {
33
41
  this.pluginsDir = opts.pluginsDir
34
42
  ?? process.env.PLUGINS_DIR
35
43
  ?? "/app/plugins";
44
+ this.hookRegistry = opts.hookRegistry;
36
45
  // Per-plugin disable via env: PLUGINS_DISABLED="prometheus,loki"
37
46
  const envDisabled = (process.env.PLUGINS_DISABLED ?? "")
38
47
  .split(",")
39
48
  .map((s) => s.trim())
40
49
  .filter(Boolean);
41
50
  this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
42
- this.verify = opts.verify ?? /^(1|true|yes)$/i.test(process.env.VERIFY_PLUGINS ?? "");
51
+ this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
43
52
  this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
44
53
  }
45
54
  async load() {
@@ -200,6 +209,48 @@ export class PluginLoader {
200
209
  manifest,
201
210
  factory,
202
211
  });
212
+ // Manifest-driven hook auto-registration (Q10). After the
213
+ // entry module loads (and is integrity/sig-verified above), walk
214
+ // manifest.hooks[] and resolve each entry's `module` against the
215
+ // plugin root. Default export is the handler. Errors during
216
+ // individual hook load are logged + skipped — they don't tear
217
+ // down the connector itself.
218
+ if (this.hookRegistry && manifest?.hooks?.length) {
219
+ // Drop any prior registrations for this plugin so a hot-reload
220
+ // doesn't leave stale entries side-by-side with new ones.
221
+ this.hookRegistry.unregisterPlugin(marker.name);
222
+ for (const hookEntry of manifest.hooks) {
223
+ const hookPath = resolve(pluginRoot, hookEntry.module);
224
+ const inside = hookPath.startsWith(resolve(pluginRoot) + "/") || hookPath === resolve(pluginRoot);
225
+ if (!inside) {
226
+ console.warn("Plugin %s hook module %s escapes the plugin root — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
227
+ continue;
228
+ }
229
+ if (!existsSync(hookPath)) {
230
+ console.warn("Plugin %s hook module %s not found — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
231
+ continue;
232
+ }
233
+ try {
234
+ const hookMod = await import(pathToFileURL(hookPath).href);
235
+ const handler = hookMod.default ?? hookMod.handler;
236
+ if (typeof handler !== "function") {
237
+ console.warn("Plugin %s hook module %s has no default export — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
238
+ continue;
239
+ }
240
+ this.hookRegistry.register({
241
+ pluginName: marker.name,
242
+ kind: hookEntry.kind,
243
+ priority: hookEntry.priority,
244
+ mode: hookEntry.mode,
245
+ handler: handler,
246
+ });
247
+ console.log('Plugin "%s": registered %s hook from %s', sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module));
248
+ }
249
+ catch (err) {
250
+ console.warn("Plugin %s hook %s/%s failed to load: %s", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module), sanitizeForLog(err instanceof Error ? err.message : String(err)));
251
+ }
252
+ }
253
+ }
203
254
  console.log('Connector plugin "%s" loaded from %s', sanitizeForLog(marker.name), sanitizeForLog(pluginRoot));
204
255
  }
205
256
  register(entry) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { PluginLoader } from "./loader.js";
7
+ function tmp() {
8
+ return mkdtempSync(join(tmpdir(), "loader-default-"));
9
+ }
10
+ function withEnv(overrides, fn) {
11
+ const saved = {};
12
+ for (const k of Object.keys(overrides)) {
13
+ saved[k] = process.env[k];
14
+ if (overrides[k] === undefined)
15
+ delete process.env[k];
16
+ else
17
+ process.env[k] = overrides[k];
18
+ }
19
+ try {
20
+ fn();
21
+ }
22
+ finally {
23
+ for (const k of Object.keys(saved)) {
24
+ if (saved[k] === undefined)
25
+ delete process.env[k];
26
+ else
27
+ process.env[k] = saved[k];
28
+ }
29
+ }
30
+ }
31
+ test("PluginLoader: VERIFY_PLUGINS defaults to ON when env var unset", () => {
32
+ withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, () => {
33
+ const loader = new PluginLoader({ pluginsDir: tmp() });
34
+ assert.equal(loader["verify"], true, "verify default should be true (fail-closed)");
35
+ });
36
+ });
37
+ test("PluginLoader: VERIFY_PLUGINS=false opts out explicitly", () => {
38
+ withEnv({ VERIFY_PLUGINS: "false" }, () => {
39
+ const loader = new PluginLoader({ pluginsDir: tmp() });
40
+ assert.equal(loader["verify"], false);
41
+ });
42
+ });
43
+ test("PluginLoader: VERIFY_PLUGINS=0 / no / off also opt out", () => {
44
+ for (const v of ["0", "no", "off", "FALSE", "Off"]) {
45
+ withEnv({ VERIFY_PLUGINS: v }, () => {
46
+ const loader = new PluginLoader({ pluginsDir: tmp() });
47
+ assert.equal(loader["verify"], false, `value ${v} should disable verify`);
48
+ });
49
+ }
50
+ });
51
+ test("PluginLoader: VERIFY_PLUGINS=true / 1 / yes keep verify on", () => {
52
+ for (const v of ["true", "1", "yes", "TRUE", "Yes"]) {
53
+ withEnv({ VERIFY_PLUGINS: v }, () => {
54
+ const loader = new PluginLoader({ pluginsDir: tmp() });
55
+ assert.equal(loader["verify"], true);
56
+ });
57
+ }
58
+ });
59
+ test("PluginLoader: opts.verify overrides env var", () => {
60
+ withEnv({ VERIFY_PLUGINS: "false" }, () => {
61
+ const onLoader = new PluginLoader({ pluginsDir: tmp(), verify: true });
62
+ assert.equal(onLoader["verify"], true);
63
+ });
64
+ withEnv({ VERIFY_PLUGINS: "true" }, () => {
65
+ const offLoader = new PluginLoader({ pluginsDir: tmp(), verify: false });
66
+ assert.equal(offLoader["verify"], false);
67
+ });
68
+ });
69
+ test("PluginLoader.load(): with verify on + no trust root → builtins still load, filesystem skipped", async () => {
70
+ withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, async () => {
71
+ const loader = new PluginLoader({ pluginsDir: tmp() });
72
+ await loader.load();
73
+ const names = loader.supportedTypes();
74
+ assert.ok(names.includes("prometheus"), "prometheus builtin must remain available");
75
+ assert.ok(names.includes("loki"), "loki builtin must remain available");
76
+ assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
77
+ });
78
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
1
+ // Manifest-driven hook auto-registration (Q10).
2
+ //
3
+ // Stages a synthetic plugin directory with:
4
+ // - package.json + manifest.json declaring hooks[]
5
+ // - index.js exporting a no-op connector factory
6
+ // - hooks/<kind>.js modules exporting handler defaults
7
+ // Runs PluginLoader (with VERIFY_PLUGINS off — we test the
8
+ // hook wiring, not the trust-root path) and asserts the
9
+ // HookRegistry now has the entries the manifest declared.
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { PluginLoader } from "./loader.js";
16
+ import { HookRegistry } from "../sdk/hooks.js";
17
+ function stagePlugin(opts) {
18
+ const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-hooks-"));
19
+ const root = join(stage, opts.name);
20
+ mkdirSync(root, { recursive: true });
21
+ writeFileSync(join(root, "package.json"), JSON.stringify({
22
+ name: `@observability-mcp/connector-${opts.name}`,
23
+ observabilityMcp: { kind: "connector", name: opts.name, manifest: "./manifest.json" },
24
+ main: "./index.js",
25
+ }));
26
+ writeFileSync(join(root, "manifest.json"), JSON.stringify({
27
+ schemaVersion: 1,
28
+ name: opts.name,
29
+ displayName: opts.name,
30
+ version: "1.0.0",
31
+ description: "test plugin",
32
+ signalTypes: ["topology"],
33
+ capabilities: { listServices: true },
34
+ compat: { serverVersion: ">=3.0.0" },
35
+ hooks: opts.hooks?.map((h) => ({
36
+ kind: h.kind,
37
+ module: h.module,
38
+ priority: h.priority,
39
+ mode: h.mode,
40
+ })),
41
+ }));
42
+ // Tiny no-op connector factory.
43
+ writeFileSync(join(root, "index.js"), opts.indexJs ??
44
+ `export default function create() {
45
+ return {
46
+ type: "${opts.name}",
47
+ signalType: "topology",
48
+ name: "${opts.name}",
49
+ async connect() {},
50
+ async healthCheck() { return { status: "down", latencyMs: 0 }; },
51
+ async disconnect() {},
52
+ getDefaultMetrics() { return []; },
53
+ getMetrics() { return []; },
54
+ async listServices() { return []; },
55
+ };
56
+ }`);
57
+ // Hook modules — each writes a marker into the global for assertions.
58
+ for (const h of opts.hooks ?? []) {
59
+ const hookPath = join(root, h.module);
60
+ mkdirSync(join(hookPath, ".."), { recursive: true });
61
+ writeFileSync(hookPath, h.body ??
62
+ `export default async function handler(ctx, payload) {
63
+ globalThis.__omcp_test_hook_calls = (globalThis.__omcp_test_hook_calls ?? []);
64
+ globalThis.__omcp_test_hook_calls.push({ plugin: ctx.principal, kind: ctx.kind, target: ctx.target });
65
+ return { allow: true, payload };
66
+ }`);
67
+ }
68
+ return stage;
69
+ }
70
+ test("PluginLoader: manifest hooks auto-register on plugin load", async () => {
71
+ const stage = stagePlugin({
72
+ name: "alpha",
73
+ hooks: [
74
+ { kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 50 },
75
+ { kind: "tool_post_invoke", module: "hooks/post.mjs" },
76
+ ],
77
+ });
78
+ const registry = new HookRegistry();
79
+ const loader = new PluginLoader({
80
+ pluginsDir: stage,
81
+ verify: false,
82
+ hookRegistry: registry,
83
+ });
84
+ await loader.load();
85
+ const pre = registry.list("tool_pre_invoke");
86
+ const post = registry.list("tool_post_invoke");
87
+ assert.equal(pre.length, 1);
88
+ assert.equal(pre[0].pluginName, "alpha");
89
+ assert.equal(pre[0].priority, 50);
90
+ assert.equal(post.length, 1);
91
+ assert.equal(post[0].pluginName, "alpha");
92
+ assert.equal(post[0].priority, 100); // default
93
+ });
94
+ test("PluginLoader: hook handlers fire end-to-end through HookRegistry", async () => {
95
+ delete globalThis.__omcp_test_hook_calls;
96
+ const stage = stagePlugin({
97
+ name: "beta",
98
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
99
+ });
100
+ const registry = new HookRegistry();
101
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
102
+ await loader.load();
103
+ const result = await registry.fire("tool_pre_invoke", { principal: "alice", tenant: "default", kind: "tool_pre_invoke", target: "tool.x" }, { args: { foo: 1 } });
104
+ assert.equal(result.allow, true);
105
+ const calls = globalThis.__omcp_test_hook_calls;
106
+ assert.ok(calls && calls.length === 1, "hook should have fired once");
107
+ });
108
+ test("PluginLoader: missing hook module is skipped with a warning, others still register", async () => {
109
+ // Stage one good hook normally, then rewrite the manifest to ALSO
110
+ // reference a sibling module path that doesn't exist on disk. The
111
+ // loader must skip the missing one (existsSync branch) and still
112
+ // register the good one.
113
+ const stage = stagePlugin({
114
+ name: "gamma",
115
+ hooks: [{ kind: "tool_post_invoke", module: "hooks/post.mjs" }],
116
+ });
117
+ const manifestPath = join(stage, "gamma", "manifest.json");
118
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
119
+ raw.hooks = [
120
+ { kind: "tool_pre_invoke", module: "hooks/genuinely-missing.mjs" },
121
+ { kind: "tool_post_invoke", module: "hooks/post.mjs" },
122
+ ];
123
+ writeFileSync(manifestPath, JSON.stringify(raw));
124
+ const registry = new HookRegistry();
125
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
126
+ // The missing pre hook was skipped; the good post hook registered.
127
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
128
+ assert.equal(registry.list("tool_post_invoke").length, 1);
129
+ });
130
+ test("PluginLoader: hook module path that escapes the plugin root is rejected", async () => {
131
+ // Stage a plugin whose manifest tries to reference a path with `..`
132
+ // — the loader must refuse to import it.
133
+ const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-escape-"));
134
+ const root = join(stage, "evil");
135
+ mkdirSync(root, { recursive: true });
136
+ writeFileSync(join(root, "package.json"), JSON.stringify({
137
+ name: "@observability-mcp/connector-evil",
138
+ observabilityMcp: { kind: "connector", name: "evil", manifest: "./manifest.json" },
139
+ main: "./index.js",
140
+ }));
141
+ writeFileSync(join(root, "manifest.json"), JSON.stringify({
142
+ schemaVersion: 1,
143
+ name: "evil",
144
+ displayName: "evil",
145
+ version: "1.0.0",
146
+ description: "x",
147
+ signalTypes: ["topology"],
148
+ capabilities: { listServices: true },
149
+ hooks: [{ kind: "tool_pre_invoke", module: "../escape.mjs" }],
150
+ }));
151
+ writeFileSync(join(root, "index.js"), `export default function create() {
152
+ return {
153
+ type: "evil", signalType: "topology", name: "evil",
154
+ async connect() {},
155
+ async healthCheck() { return { status: "down", latencyMs: 0 }; },
156
+ async disconnect() {},
157
+ getDefaultMetrics() { return []; },
158
+ getMetrics() { return []; },
159
+ async listServices() { return []; },
160
+ };
161
+ }`);
162
+ // Stage a file outside the plugin root the manifest references.
163
+ writeFileSync(join(stage, "escape.mjs"), `export default async () => ({ allow: false, reason: "should never run" });`);
164
+ const registry = new HookRegistry();
165
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
166
+ await loader.load();
167
+ // The escape hook was refused; nothing in the registry.
168
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
169
+ });
170
+ test("PluginLoader: hot-reload — re-loading replaces prior hook registrations", async () => {
171
+ const stage = stagePlugin({
172
+ name: "delta",
173
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 10 }],
174
+ });
175
+ const registry = new HookRegistry();
176
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
177
+ assert.equal(registry.list("tool_pre_invoke").length, 1);
178
+ // Re-load (same stage, same plugin) — registry should still hold
179
+ // exactly one entry for tool_pre_invoke owned by delta. The loader
180
+ // calls unregisterPlugin first, then re-registers.
181
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
182
+ const after = registry.list("tool_pre_invoke");
183
+ assert.equal(after.length, 1);
184
+ assert.equal(after[0].pluginName, "delta");
185
+ });
186
+ test("PluginLoader: no hookRegistry passed → hooks are silently ignored (back-compat)", async () => {
187
+ const stage = stagePlugin({
188
+ name: "epsilon",
189
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
190
+ });
191
+ // No hookRegistry — load() must not throw.
192
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false });
193
+ await loader.load();
194
+ assert.ok(loader.has("epsilon"));
195
+ });
196
+ test("PluginLoader: hook with no default export is skipped", async () => {
197
+ const stage = stagePlugin({
198
+ name: "zeta",
199
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/noexport.mjs" }],
200
+ });
201
+ // Overwrite the noexport.mjs file to remove default export.
202
+ writeFileSync(join(stage, "zeta", "hooks", "noexport.mjs"), "export const meta = 'no handler here';");
203
+ const registry = new HookRegistry();
204
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
205
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
206
+ });
@@ -77,27 +77,45 @@ describe("PrometheusConnector", () => {
77
77
  });
78
78
  });
79
79
  describe("buildQuery", () => {
80
- it("replaces {{service}} placeholder in known metrics", async () => {
80
+ // buildQuery is private async and returns `{ promql, label, candidate }`.
81
+ // To keep these tests off the network, every case uses a user-
82
+ // override metric — that short-circuits the candidate-probe path
83
+ // (which would otherwise call Prometheus to pick the best variant
84
+ // and resolveServiceLabel to discover the right scoping label).
85
+ // The label / candidate fields are exercised via the public
86
+ // queryMetrics path elsewhere.
87
+ const fakeSource = { name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true };
88
+ it("replaces {{service}} placeholder in user-defined metrics", async () => {
81
89
  const connector = new PrometheusConnector();
82
- await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
83
- const query = proto.buildQuery.call(connector, "payment-service", "cpu");
84
- assert.ok(query.includes("payment-service"));
85
- assert.ok(!query.includes("{{service}}"));
86
- });
87
- it("falls back to generic query for unknown metrics", async () => {
90
+ await connector.connect({
91
+ ...fakeSource,
92
+ metrics: [{ name: "cpu", query: 'cpu_usage{svc="{{service}}"}', unit: "%", description: "CPU" }],
93
+ });
94
+ const { promql } = await proto.buildQuery.call(connector, "payment-service", "cpu");
95
+ assert.ok(promql.includes("payment-service"));
96
+ assert.ok(!promql.includes("{{service}}"));
97
+ });
98
+ it("respects an explicit {{service}} substitution outside the {{selector}} sugar", async () => {
99
+ // Different from the other two: the override here uses {{service}}
100
+ // directly inside a hand-written selector (no {{selector}} sugar).
101
+ // Confirms the substitution applies to the raw template, not only
102
+ // through the label-resolver path.
88
103
  const connector = new PrometheusConnector();
89
- await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
90
- const query = proto.buildQuery.call(connector, "my-svc", "unknown_metric");
91
- assert.equal(query, 'unknown_metric{job="my-svc"}');
104
+ await connector.connect({
105
+ ...fakeSource,
106
+ metrics: [{ name: "explicit_selector", query: 'explicit_metric{job="{{service}}"}', unit: "", description: "" }],
107
+ });
108
+ const { promql } = await proto.buildQuery.call(connector, "my-svc", "explicit_selector");
109
+ assert.equal(promql, 'explicit_metric{job="my-svc"}');
92
110
  });
93
111
  it("uses custom metrics from source config", async () => {
94
112
  const connector = new PrometheusConnector();
95
113
  await connector.connect({
96
- name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true,
114
+ ...fakeSource,
97
115
  metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
98
116
  });
99
- const query = proto.buildQuery.call(connector, "api", "custom");
100
- assert.equal(query, 'my_custom_metric{svc="api"}');
117
+ const { promql } = await proto.buildQuery.call(connector, "api", "custom");
118
+ assert.equal(promql, 'my_custom_metric{svc="api"}');
101
119
  });
102
120
  });
103
121
  });
@@ -17,5 +17,18 @@ export declare class ConnectorRegistry {
17
17
  getAll(): ObservabilityConnector[];
18
18
  getByName(name: string): ObservabilityConnector | undefined;
19
19
  getBySignal(signal: SignalType): ObservabilityConnector[];
20
+ /** Connectors visible to the named tenant: every source whose
21
+ * config.tenant matches OR is unset (global). Unset = available
22
+ * everywhere — keeps single-tenant deployments untouched.
23
+ * Anonymous traffic / the agent / internal callers can pass
24
+ * the DEFAULT_TENANT sentinel and see exactly what the default-
25
+ * tenant operator sees. */
26
+ getByTenant(tenant: string): ObservabilityConnector[];
27
+ /** Same as `getByName`, but enforces the tenant gate: a source
28
+ * whose config.tenant is set and differs from the calling tenant
29
+ * returns undefined — indistinguishable from "no such source",
30
+ * per the rest of the tenancy layer (no cross-tenant existence
31
+ * leak). Unset source tenant = global, always resolves. */
32
+ getByNameForTenant(name: string, tenant: string): ObservabilityConnector | undefined;
20
33
  healthCheckAll(): Promise<Record<string, ConnectorHealth>>;
21
34
  }
@@ -80,6 +80,36 @@ export class ConnectorRegistry {
80
80
  getBySignal(signal) {
81
81
  return this.getAll().filter((c) => c.signalType === signal);
82
82
  }
83
+ /** Connectors visible to the named tenant: every source whose
84
+ * config.tenant matches OR is unset (global). Unset = available
85
+ * everywhere — keeps single-tenant deployments untouched.
86
+ * Anonymous traffic / the agent / internal callers can pass
87
+ * the DEFAULT_TENANT sentinel and see exactly what the default-
88
+ * tenant operator sees. */
89
+ getByTenant(tenant) {
90
+ const out = [];
91
+ for (const [name, c] of this.connectors) {
92
+ const cfg = this.sourceConfigs.get(name);
93
+ const srcTenant = cfg?.tenant;
94
+ if (!srcTenant || srcTenant === tenant)
95
+ out.push(c);
96
+ }
97
+ return out;
98
+ }
99
+ /** Same as `getByName`, but enforces the tenant gate: a source
100
+ * whose config.tenant is set and differs from the calling tenant
101
+ * returns undefined — indistinguishable from "no such source",
102
+ * per the rest of the tenancy layer (no cross-tenant existence
103
+ * leak). Unset source tenant = global, always resolves. */
104
+ getByNameForTenant(name, tenant) {
105
+ const c = this.connectors.get(name);
106
+ if (!c)
107
+ return undefined;
108
+ const cfg = this.sourceConfigs.get(name);
109
+ if (cfg?.tenant && cfg.tenant !== tenant)
110
+ return undefined;
111
+ return c;
112
+ }
83
113
  async healthCheckAll() {
84
114
  const results = {};
85
115
  for (const [name, connector] of this.connectors) {
@@ -2,15 +2,21 @@ import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { getSupportedTypes, ConnectorRegistry } from "./registry.js";
4
4
  import { DEFAULT_SETTINGS, DEFAULT_HEALTH_THRESHOLDS } from "../config/loader.js";
5
+ import { getPluginLoader } from "./loader.js";
5
6
  function makeConfig(sources = []) {
6
7
  return { sources, settings: DEFAULT_SETTINGS, healthThresholds: DEFAULT_HEALTH_THRESHOLDS };
7
8
  }
8
9
  describe("getSupportedTypes", () => {
9
- it("returns prometheus and loki", () => {
10
+ it("returns the builtins (prometheus, loki, kubernetes) after loader.load()", async () => {
11
+ // The PluginLoader registers builtins inside load(), not the
12
+ // constructor — at server boot index.ts awaits load() before any
13
+ // tool registration code runs. Mirror that here so the test
14
+ // reflects the real wiring rather than a transient empty state.
15
+ await getPluginLoader().load();
10
16
  const types = getSupportedTypes();
11
17
  assert.ok(types.includes("prometheus"));
12
18
  assert.ok(types.includes("loki"));
13
- assert.equal(types.length, 2);
19
+ assert.ok(types.includes("kubernetes"));
14
20
  });
15
21
  });
16
22
  describe("ConnectorRegistry", () => {
@@ -90,4 +96,52 @@ describe("ConnectorRegistry", () => {
90
96
  assert.deepEqual(results, {});
91
97
  });
92
98
  });
99
+ describe("getByTenant / getByNameForTenant", () => {
100
+ it("untagged sources are visible to every tenant (pre-E7 single-tenant default)", async () => {
101
+ await getPluginLoader().load();
102
+ const reg = new ConnectorRegistry();
103
+ await reg.initialize(makeConfig([
104
+ // No tenant on either source — both are "global".
105
+ { name: "prom-global", type: "prometheus", url: "http://p:9090", enabled: true },
106
+ { name: "loki-global", type: "loki", url: "http://l:3100", enabled: true },
107
+ ]));
108
+ const acmeVisible = reg.getByTenant("acme").map((c) => c.name).sort();
109
+ const bigcoVisible = reg.getByTenant("bigco").map((c) => c.name).sort();
110
+ assert.deepEqual(acmeVisible, ["loki-global", "prom-global"]);
111
+ assert.deepEqual(bigcoVisible, ["loki-global", "prom-global"]);
112
+ });
113
+ it("tenant-tagged source is invisible to other tenants", async () => {
114
+ await getPluginLoader().load();
115
+ const reg = new ConnectorRegistry();
116
+ await reg.initialize(makeConfig([
117
+ { name: "shared", type: "prometheus", url: "http://p:9090", enabled: true },
118
+ { name: "acme-only", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
119
+ ]));
120
+ assert.deepEqual(reg.getByTenant("acme").map((c) => c.name).sort(), ["acme-only", "shared"]);
121
+ // bigco sees only the shared source — the acme-only one is hidden.
122
+ assert.deepEqual(reg.getByTenant("bigco").map((c) => c.name).sort(), ["shared"]);
123
+ });
124
+ it("getByNameForTenant returns undefined on cross-tenant probe (no existence leak)", async () => {
125
+ await getPluginLoader().load();
126
+ const reg = new ConnectorRegistry();
127
+ await reg.initialize(makeConfig([
128
+ { name: "acme-loki", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
129
+ ]));
130
+ // Within tenant: resolves.
131
+ assert.ok(reg.getByNameForTenant("acme-loki", "acme"));
132
+ // Cross-tenant: undefined — indistinguishable from "no such source".
133
+ assert.equal(reg.getByNameForTenant("acme-loki", "bigco"), undefined);
134
+ // Unknown name in own tenant: also undefined.
135
+ assert.equal(reg.getByNameForTenant("nope", "acme"), undefined);
136
+ });
137
+ it("a source whose tenant is unset resolves for every tenant via getByNameForTenant", async () => {
138
+ await getPluginLoader().load();
139
+ const reg = new ConnectorRegistry();
140
+ await reg.initialize(makeConfig([
141
+ { name: "global", type: "prometheus", url: "http://p:9090", enabled: true },
142
+ ]));
143
+ assert.ok(reg.getByNameForTenant("global", "acme"));
144
+ assert.ok(reg.getByNameForTenant("global", "bigco"));
145
+ });
146
+ });
93
147
  });