@thotischner/observability-mcp 1.4.0 → 1.5.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 (68) hide show
  1. package/dist/analysis/anomaly.d.ts +89 -0
  2. package/dist/analysis/anomaly.js +235 -0
  3. package/dist/analysis/anomaly.test.js +149 -1
  4. package/dist/analysis/backtest.d.ts +31 -0
  5. package/dist/analysis/backtest.js +206 -0
  6. package/dist/analysis/backtest.test.d.ts +1 -0
  7. package/dist/analysis/backtest.test.js +34 -0
  8. package/dist/analysis/correlator.d.ts +35 -0
  9. package/dist/analysis/correlator.js +95 -0
  10. package/dist/analysis/correlator.test.js +60 -1
  11. package/dist/analysis/health.d.ts +2 -3
  12. package/dist/analysis/index.d.ts +32 -0
  13. package/dist/analysis/index.js +29 -0
  14. package/dist/analysis/library.test.d.ts +1 -0
  15. package/dist/analysis/library.test.js +44 -0
  16. package/dist/auth/credentials.d.ts +29 -0
  17. package/dist/auth/credentials.js +76 -0
  18. package/dist/auth/credentials.test.d.ts +1 -0
  19. package/dist/auth/credentials.test.js +57 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +370 -0
  22. package/dist/cli/lib.d.ts +95 -0
  23. package/dist/cli/lib.js +185 -0
  24. package/dist/cli/lib.test.d.ts +1 -0
  25. package/dist/cli/lib.test.js +134 -0
  26. package/dist/connectors/hub.d.ts +48 -0
  27. package/dist/connectors/hub.js +51 -0
  28. package/dist/connectors/hub.test.d.ts +1 -0
  29. package/dist/connectors/hub.test.js +52 -0
  30. package/dist/connectors/install.d.ts +24 -0
  31. package/dist/connectors/install.js +100 -0
  32. package/dist/connectors/install.test.d.ts +1 -0
  33. package/dist/connectors/install.test.js +58 -0
  34. package/dist/connectors/loader.d.ts +5 -0
  35. package/dist/connectors/loader.js +54 -2
  36. package/dist/connectors/loki.js +11 -4
  37. package/dist/connectors/loki.test.js +27 -0
  38. package/dist/connectors/verify.d.ts +19 -0
  39. package/dist/connectors/verify.js +87 -0
  40. package/dist/connectors/verify.test.d.ts +1 -0
  41. package/dist/connectors/verify.test.js +63 -0
  42. package/dist/context.d.ts +27 -0
  43. package/dist/context.js +18 -0
  44. package/dist/index.js +322 -34
  45. package/dist/net/egress-policy.d.ts +31 -0
  46. package/dist/net/egress-policy.js +37 -0
  47. package/dist/net/egress-policy.test.d.ts +1 -0
  48. package/dist/net/egress-policy.test.js +52 -0
  49. package/dist/sdk/index.d.ts +6 -0
  50. package/dist/sdk/manifest-schema.d.ts +1 -0
  51. package/dist/sdk/manifest-schema.js +11 -0
  52. package/dist/tools/context-seam.test.d.ts +1 -0
  53. package/dist/tools/context-seam.test.js +23 -0
  54. package/dist/tools/detect-anomalies.d.ts +2 -1
  55. package/dist/tools/detect-anomalies.js +47 -11
  56. package/dist/tools/get-service-health.d.ts +2 -1
  57. package/dist/tools/get-service-health.js +2 -1
  58. package/dist/tools/handlers.test.js +73 -0
  59. package/dist/tools/list-services.d.ts +2 -1
  60. package/dist/tools/list-services.js +2 -1
  61. package/dist/tools/list-sources.d.ts +2 -1
  62. package/dist/tools/list-sources.js +2 -1
  63. package/dist/tools/query-logs.d.ts +2 -1
  64. package/dist/tools/query-logs.js +2 -1
  65. package/dist/tools/query-metrics.d.ts +2 -1
  66. package/dist/tools/query-metrics.js +9 -1
  67. package/dist/ui/index.html +119 -4
  68. package/package.json +18 -5
@@ -33,4 +33,15 @@ export const manifestSchema = z.object({
33
33
  serverVersion: z.string().optional(),
34
34
  })
35
35
  .optional(),
36
+ // Subresource-integrity-style digest of the plugin entry file
37
+ // ("sha256-<base64>"). When the server runs with VERIFY_PLUGINS=true
38
+ // this MUST be present and match the on-disk entry, and the manifest
39
+ // itself MUST carry a valid detached signature. Airgapped-friendly:
40
+ // verification is fully offline against a local trust-root key.
41
+ integrity: z
42
+ .string()
43
+ .regex(/^sha256-[A-Za-z0-9+/]+=*$/, {
44
+ message: 'integrity must be "sha256-<base64>"',
45
+ })
46
+ .optional(),
36
47
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ // Keystone guard: every tool handler must accept the RequestContext seam.
7
+ // This prevents a new handler (or a refactor) from silently bypassing the
8
+ // request-scoped context that access-control / scoping / audit attach to.
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ describe("RequestContext seam", () => {
11
+ const handlerFiles = readdirSync(here).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
12
+ for (const file of handlerFiles) {
13
+ const src = readFileSync(join(here, file), "utf8");
14
+ const hasHandler = /export\s+(async\s+)?function\s+\w*Handler\s*\(/.test(src);
15
+ if (!hasHandler)
16
+ continue;
17
+ it(`${file}: handler accepts a RequestContext`, () => {
18
+ assert.match(src, /_ctx:\s*RequestContext/, `${file} exports a *Handler but does not thread RequestContext — ` +
19
+ `add the ctx seam (see context.ts)`);
20
+ assert.match(src, /from "\.\.\/context\.js"/, `${file} must import from ../context.js`);
21
+ });
22
+ }
23
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  export declare const detectAnomaliesDefinition: {
3
4
  name: "detect_anomalies";
4
5
  description: string;
@@ -25,7 +26,7 @@ export declare function detectAnomaliesHandler(registry: ConnectorRegistry, args
25
26
  service?: string;
26
27
  duration?: string;
27
28
  sensitivity?: string;
28
- }): Promise<{
29
+ }, _ctx?: RequestContext): Promise<{
29
30
  content: {
30
31
  type: "text";
31
32
  text: string;
@@ -1,4 +1,6 @@
1
- import { detectRecentAnomaly } from "../analysis/anomaly.js";
1
+ import { defaultContext } from "../context.js";
2
+ import { detectAnomaly, classifyMetric } from "../analysis/anomaly.js";
3
+ import { rankRootCause } from "../analysis/correlator.js";
2
4
  export const detectAnomaliesDefinition = {
3
5
  name: "detect_anomalies",
4
6
  description: "Scan for anomalies across all monitored services (or a specific service). Detects metric deviations using z-score analysis against recent baseline, checks log error spikes, and correlates signals across metrics and logs. Returns anomalies with severity ratings and cross-signal correlations.",
@@ -26,8 +28,12 @@ const SENSITIVITY_THRESHOLDS = {
26
28
  medium: 2.0,
27
29
  high: 1.5,
28
30
  };
29
- const KEY_METRICS = ["cpu", "error_rate", "latency_p99", "request_rate"];
30
- export async function detectAnomaliesHandler(registry, args) {
31
+ const KEY_METRICS = ["cpu", "memory", "error_rate", "latency_p99", "request_rate"];
32
+ // Patterns that signal a serious incident even at warn level and even when
33
+ // the overall error ratio is low (e.g. a memory leak emits a handful of
34
+ // "OutOfMemoryWarning" lines long before it turns into 5xx errors).
35
+ const CRITICAL_LOG_PATTERN = /\b(out\s?of\s?memory|oom|outofmemory|heap (usage|exhaust)|memory leak|panic|fatal|deadlock|segfault|stack overflow|cannot allocate)\b/i;
36
+ export async function detectAnomaliesHandler(registry, args, _ctx = defaultContext()) {
31
37
  const duration = args.duration || "10m";
32
38
  const threshold = SENSITIVITY_THRESHOLDS[args.sensitivity || "medium"] || 2.0;
33
39
  // Discover services to scan
@@ -56,18 +62,21 @@ export async function detectAnomaliesHandler(registry, args) {
56
62
  for (const metric of KEY_METRICS) {
57
63
  try {
58
64
  const result = await connector.queryMetrics({ service: serviceName, metric, duration });
59
- const values = result.values.map((v) => v.value);
60
- const anomaly = detectRecentAnomaly(values, 5, threshold);
65
+ const points = result.values.map((v) => ({ timestamp: v.timestamp, value: v.value }));
66
+ const anomaly = detectAnomaly(points, {
67
+ threshold,
68
+ metricKind: classifyMetric(metric),
69
+ });
61
70
  if (anomaly.isAnomaly) {
62
- const deviationPercent = anomaly.baselineAvg === 0
71
+ const deviationPercent = anomaly.baselineValue === 0
63
72
  ? 100
64
- : Math.round(((anomaly.recentAvg - anomaly.baselineAvg) / anomaly.baselineAvg) * 100);
73
+ : Math.round(((anomaly.recentValue - anomaly.baselineValue) / anomaly.baselineValue) * 100);
65
74
  allAnomalies.push({
66
75
  metric,
67
- severity: Math.abs(anomaly.zScore) >= 3 ? "high" : Math.abs(anomaly.zScore) >= 2 ? "medium" : "low",
68
- description: `${metric} is ${anomaly.zScore.toFixed(1)}σ ${anomaly.zScore > 0 ? "above" : "below"} baseline (${anomaly.baselineAvg.toFixed(2)} → ${anomaly.recentAvg.toFixed(2)})`,
69
- currentValue: anomaly.recentAvg,
70
- baselineValue: anomaly.baselineAvg,
76
+ severity: Math.abs(anomaly.score) >= 6 ? "high" : Math.abs(anomaly.score) >= 4 ? "medium" : "low",
77
+ description: `${metric}: ${anomaly.reason}`,
78
+ currentValue: anomaly.recentValue,
79
+ baselineValue: anomaly.baselineValue,
71
80
  deviationPercent,
72
81
  source: connector.name,
73
82
  service: serviceName,
@@ -85,6 +94,21 @@ export async function detectAnomaliesHandler(registry, args) {
85
94
  continue;
86
95
  try {
87
96
  const logs = await connector.queryLogs({ service: serviceName, duration, limit: 500 });
97
+ // Critical-pattern scan — independent of the error-ratio gate, so a
98
+ // warn-level OOM/leak signal is not silently dropped.
99
+ const criticalPattern = logs.summary.topPatterns.find((p) => CRITICAL_LOG_PATTERN.test(p));
100
+ if (criticalPattern) {
101
+ allAnomalies.push({
102
+ metric: "log_critical_pattern",
103
+ severity: "high",
104
+ description: `Critical log pattern detected: "${criticalPattern}"`,
105
+ currentValue: logs.summary.errorCount + logs.summary.warnCount,
106
+ baselineValue: 0,
107
+ deviationPercent: 100,
108
+ source: connector.name,
109
+ service: serviceName,
110
+ });
111
+ }
88
112
  if (logs.summary.errorCount > 5) {
89
113
  const errorRatio = logs.summary.total > 0
90
114
  ? logs.summary.errorCount / logs.summary.total
@@ -123,10 +147,22 @@ export async function detectAnomaliesHandler(registry, args) {
123
147
  }
124
148
  }
125
149
  }
150
+ // Dependency-aware root-cause ranking. The service graph / change markers
151
+ // are empty here (no trace source wired yet); ranking then degrades to
152
+ // severity-weighted ordering and still names the most likely culprit
153
+ // instead of just listing "both signals bad".
154
+ const rootCause = allAnomalies.length > 0
155
+ ? rankRootCause(allAnomalies.map((a) => ({
156
+ service: a.service,
157
+ metric: a.metric,
158
+ severity: a.severity,
159
+ })))
160
+ : { ranked: [], summary: "" };
126
161
  const result = {
127
162
  scannedServices: serviceNames.length,
128
163
  anomalies: allAnomalies,
129
164
  correlations: allCorrelations,
165
+ rootCause,
130
166
  summary: allAnomalies.length === 0
131
167
  ? "All services healthy — no anomalies detected."
132
168
  : `${allAnomalies.length} anomal${allAnomalies.length === 1 ? "y" : "ies"} detected across ${[...new Set(allAnomalies.map((a) => a.service))].length} service(s).`,
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  import type { HealthThresholds } from "../types.js";
3
4
  export declare function setHealthThresholds(t: HealthThresholds): void;
4
5
  export declare const getServiceHealthDefinition: {
@@ -17,7 +18,7 @@ export declare const getServiceHealthDefinition: {
17
18
  };
18
19
  export declare function getServiceHealthHandler(registry: ConnectorRegistry, args: {
19
20
  service: string;
20
- }): Promise<{
21
+ }, _ctx?: RequestContext): Promise<{
21
22
  content: {
22
23
  type: "text";
23
24
  text: string;
@@ -1,3 +1,4 @@
1
+ import { defaultContext } from "../context.js";
1
2
  import { calculateHealthScore } from "../analysis/health.js";
2
3
  import { detectRecentAnomaly } from "../analysis/anomaly.js";
3
4
  import { sanitizeForLog } from "../util/sanitize.js";
@@ -19,7 +20,7 @@ export const getServiceHealthDefinition = {
19
20
  required: ["service"],
20
21
  },
21
22
  };
22
- export async function getServiceHealthHandler(registry, args) {
23
+ export async function getServiceHealthHandler(registry, args, _ctx = defaultContext()) {
23
24
  const metricsConnectors = registry.getBySignal("metrics");
24
25
  const logConnectors = registry.getBySignal("logs");
25
26
  // Gather metrics
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { ConnectorRegistry } from "../connectors/registry.js";
4
4
  import { listSourcesHandler } from "./list-sources.js";
5
5
  import { listServicesHandler } from "./list-services.js";
6
+ import { detectAnomaliesHandler } from "./detect-anomalies.js";
6
7
  // --- Mock Connector ---
7
8
  function createMockConnector(overrides) {
8
9
  return {
@@ -136,3 +137,75 @@ describe("listServicesHandler", () => {
136
137
  assert.equal(data.total, 0);
137
138
  });
138
139
  });
140
+ describe("detectAnomaliesHandler — A5 memory/OOM coverage", () => {
141
+ const flatMemory = () => ({
142
+ source: "prom1", service: "payment-service", metric: "memory", unit: "bytes",
143
+ values: Array.from({ length: 40 }, (_, i) => ({
144
+ timestamp: new Date(Date.now() - (40 - i) * 9000).toISOString(),
145
+ value: 1.3e8 + (i % 3) * 1e6, // noisy, no trend → no metric anomaly
146
+ })),
147
+ summary: { current: 1.3e8, average: 1.3e8, min: 1.28e8, max: 1.33e8, trend: "stable" },
148
+ });
149
+ it("scans the memory metric (now in KEY_METRICS)", async () => {
150
+ const requested = [];
151
+ const reg = createRegistryWithMocks([
152
+ createMockConnector({
153
+ name: "prom1", type: "prometheus", signalType: "metrics",
154
+ listServices: async () => [{ name: "payment-service", source: "prom1", signalType: "metrics" }],
155
+ queryMetrics: async ({ metric }) => {
156
+ requested.push(metric);
157
+ return flatMemory();
158
+ },
159
+ }),
160
+ ]);
161
+ await detectAnomaliesHandler(reg, {});
162
+ assert.ok(requested.includes("memory"), `memory not scanned; got ${requested.join(",")}`);
163
+ });
164
+ it("flags a warn-level OOM log pattern below the error-rate gate", async () => {
165
+ const reg = createRegistryWithMocks([
166
+ createMockConnector({
167
+ name: "prom1", type: "prometheus", signalType: "metrics",
168
+ listServices: async () => [{ name: "payment-service", source: "prom1", signalType: "metrics" }],
169
+ queryMetrics: async () => flatMemory(),
170
+ }),
171
+ createMockConnector({
172
+ name: "loki1", type: "loki", signalType: "logs",
173
+ queryLogs: async () => ({
174
+ source: "loki1", service: "payment-service", entries: [],
175
+ // Only 4 warn-level lines: errorCount below the >5 gate, ratio tiny.
176
+ summary: {
177
+ total: 800, errorCount: 4, warnCount: 4,
178
+ topPatterns: ["OutOfMemoryWarning: heap usage exceeding threshold (4x)"],
179
+ },
180
+ }),
181
+ }),
182
+ ]);
183
+ const result = await detectAnomaliesHandler(reg, {});
184
+ const data = JSON.parse(result.content[0].text);
185
+ const crit = data.anomalies.find((a) => a.metric === "log_critical_pattern");
186
+ assert.ok(crit, "expected a log_critical_pattern anomaly for the OOM warning");
187
+ assert.equal(crit.service, "payment-service");
188
+ assert.equal(crit.severity, "high");
189
+ assert.equal(data.rootCause.ranked[0].service, "payment-service");
190
+ assert.notEqual(data.summary, "All services healthy — no anomalies detected.");
191
+ });
192
+ it("does not flag benign warn patterns", async () => {
193
+ const reg = createRegistryWithMocks([
194
+ createMockConnector({
195
+ name: "prom1", type: "prometheus", signalType: "metrics",
196
+ listServices: async () => [{ name: "order-service", source: "prom1", signalType: "metrics" }],
197
+ queryMetrics: async () => flatMemory(),
198
+ }),
199
+ createMockConnector({
200
+ name: "loki1", type: "loki", signalType: "logs",
201
+ queryLogs: async () => ({
202
+ source: "loki1", service: "order-service", entries: [],
203
+ summary: { total: 500, errorCount: 1, warnCount: 2, topPatterns: ["cache miss for key user:42"] },
204
+ }),
205
+ }),
206
+ ]);
207
+ const result = await detectAnomaliesHandler(reg, {});
208
+ const data = JSON.parse(result.content[0].text);
209
+ assert.equal(data.anomalies.length, 0);
210
+ });
211
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  export declare const listServicesDefinition: {
3
4
  name: "list_services";
4
5
  description: string;
@@ -14,7 +15,7 @@ export declare const listServicesDefinition: {
14
15
  };
15
16
  export declare function listServicesHandler(registry: ConnectorRegistry, args: {
16
17
  filter?: string;
17
- }): Promise<{
18
+ }, _ctx?: RequestContext): Promise<{
18
19
  content: {
19
20
  type: "text";
20
21
  text: string;
@@ -1,3 +1,4 @@
1
+ import { defaultContext } from "../context.js";
1
2
  export const listServicesDefinition = {
2
3
  name: "list_services",
3
4
  description: "List all monitored services discovered across all connected backends. Returns service names, their data sources, and signal types (metrics/logs).",
@@ -11,7 +12,7 @@ export const listServicesDefinition = {
11
12
  },
12
13
  },
13
14
  };
14
- export async function listServicesHandler(registry, args) {
15
+ export async function listServicesHandler(registry, args, _ctx = defaultContext()) {
15
16
  const connectors = registry.getAll();
16
17
  const allServices = [];
17
18
  for (const connector of connectors) {
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  export declare const listSourcesDefinition: {
3
4
  name: "list_sources";
4
5
  description: string;
@@ -7,7 +8,7 @@ export declare const listSourcesDefinition: {
7
8
  properties: {};
8
9
  };
9
10
  };
10
- export declare function listSourcesHandler(registry: ConnectorRegistry): Promise<{
11
+ export declare function listSourcesHandler(registry: ConnectorRegistry, _ctx?: RequestContext): Promise<{
11
12
  content: {
12
13
  type: "text";
13
14
  text: string;
@@ -1,3 +1,4 @@
1
+ import { defaultContext } from "../context.js";
1
2
  export const listSourcesDefinition = {
2
3
  name: "list_sources",
3
4
  description: "List all configured observability backends and their connection status. Use this to discover what data sources are available.",
@@ -6,7 +7,7 @@ export const listSourcesDefinition = {
6
7
  properties: {},
7
8
  },
8
9
  };
9
- export async function listSourcesHandler(registry) {
10
+ export async function listSourcesHandler(registry, _ctx = defaultContext()) {
10
11
  const healthResults = await registry.healthCheckAll();
11
12
  const connectors = registry.getAll();
12
13
  const sources = connectors.map((c) => ({
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  export declare const queryLogsDefinition: {
3
4
  name: "query_logs";
4
5
  description: string;
@@ -35,7 +36,7 @@ export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
35
36
  duration?: string;
36
37
  level?: string;
37
38
  limit?: number;
38
- }): Promise<{
39
+ }, _ctx?: RequestContext): Promise<{
39
40
  content: {
40
41
  type: "text";
41
42
  text: string;
@@ -1,3 +1,4 @@
1
+ import { defaultContext } from "../context.js";
1
2
  import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
2
3
  export const queryLogsDefinition = {
3
4
  name: "query_logs",
@@ -29,7 +30,7 @@ export const queryLogsDefinition = {
29
30
  required: ["service"],
30
31
  },
31
32
  };
32
- export async function queryLogsHandler(registry, args) {
33
+ export async function queryLogsHandler(registry, args, _ctx = defaultContext()) {
33
34
  const svcErr = validateServiceName(args.service);
34
35
  if (svcErr)
35
36
  return errorResponse(svcErr);
@@ -1,4 +1,5 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
2
3
  export declare const queryMetricsDefinition: {
3
4
  name: "query_metrics";
4
5
  description: string;
@@ -35,7 +36,7 @@ export declare function queryMetricsHandler(registry: ConnectorRegistry, args: {
35
36
  duration?: string;
36
37
  source?: string;
37
38
  groupBy?: string;
38
- }): Promise<{
39
+ }, _ctx?: RequestContext): Promise<{
39
40
  content: {
40
41
  type: "text";
41
42
  text: string;
@@ -1,3 +1,4 @@
1
+ import { defaultContext } from "../context.js";
1
2
  import { validateDuration, validateMetricName, validateServiceName, errorResponse } from "./validation.js";
2
3
  export const queryMetricsDefinition = {
3
4
  name: "query_metrics",
@@ -29,7 +30,14 @@ export const queryMetricsDefinition = {
29
30
  required: ["service", "metric"],
30
31
  },
31
32
  };
32
- export async function queryMetricsHandler(registry, args) {
33
+ export async function queryMetricsHandler(registry, args, _ctx = defaultContext()) {
34
+ // Coarse single-tenant source scoping: if the principal is restricted to a
35
+ // source allow-list, deny an explicit out-of-scope source.
36
+ if (_ctx.allowedSources &&
37
+ args.source &&
38
+ !_ctx.allowedSources.includes(args.source)) {
39
+ return errorResponse(`forbidden: source "${args.source}" is not in your allowed sources`);
40
+ }
33
41
  const svcErr = validateServiceName(args.service);
34
42
  if (svcErr)
35
43
  return errorResponse(svcErr);
@@ -122,6 +122,7 @@
122
122
  .badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; box-shadow: 0 0 6px currentColor; }
123
123
  .badge-ok { background: var(--success-soft); color: var(--success); border: 1px solid rgba(74,222,128,0.25); }
124
124
  .badge-err { background: var(--danger-soft); color: var(--danger); border: 1px solid rgba(239,91,110,0.30); }
125
+ .hidden { display: none !important; }
125
126
 
126
127
  .nav { display: flex; gap: 2px; margin-left: var(--sp-6); }
127
128
  .nav-btn {
@@ -490,6 +491,7 @@
490
491
  <button class="nav-btn active" data-page="dashboard" onclick="showPage('dashboard')">Dashboard</button>
491
492
  <button class="nav-btn" data-page="sources" onclick="showPage('sources')">Sources</button>
492
493
  <button class="nav-btn" data-page="services" onclick="showPage('services')">Services</button>
494
+ <button class="nav-btn" data-page="connectors" onclick="showPage('connectors')">Connectors</button>
493
495
  <button class="nav-btn" data-page="health" onclick="showPage('health')">Health</button>
494
496
  <button class="nav-btn" data-page="settings" onclick="showPage('settings')">Settings</button>
495
497
  </div>
@@ -549,6 +551,40 @@
549
551
  <div class="card"><div class="card-header"><h2>Discovered Services</h2></div><div id="services-list"><div class="empty">Loading...</div></div></div>
550
552
  </div>
551
553
 
554
+ <!-- ===== Connectors ===== -->
555
+ <div class="page" id="page-connectors">
556
+ <div class="card" style="padding:0;">
557
+ <div class="tabs">
558
+ <button class="tab-btn active" onclick="showTab('installed')">Installed</button>
559
+ <button class="tab-btn" onclick="showTab('hub')">Connector Hub</button>
560
+ <button class="tab-btn" onclick="showTab('upload')">Upload bundle</button>
561
+ </div>
562
+
563
+ <!-- Installed Tab -->
564
+ <div class="tab-content active" id="tab-installed" style="padding:20px;">
565
+ <div class="card-header" style="margin-bottom:12px"><h2>Installed connectors</h2><button class="btn btn-ghost btn-sm" onclick="loadConnectors()">Refresh</button></div>
566
+ <div id="conn-installed"><div class="empty">Loading...</div></div>
567
+ </div>
568
+
569
+ <!-- Connector Hub Tab -->
570
+ <div class="tab-content" id="tab-hub" style="padding:20px;">
571
+ <div class="card-header" style="margin-bottom:12px"><h2>Available from the Connector Hub</h2>
572
+ <a class="btn btn-ghost btn-sm" href="https://thotischner.github.io/observability-mcp/hub/" target="_blank" rel="noopener">Open Hub ↗</a></div>
573
+ <div id="conn-hub"><div class="empty">Loading...</div></div>
574
+ </div>
575
+
576
+ <!-- Upload bundle Tab -->
577
+ <div class="tab-content" id="tab-upload" style="padding:20px;">
578
+ <div class="card-header" style="margin-bottom:12px"><h2>Upload a connector bundle</h2></div>
579
+ <p class="empty" style="margin:0 0 12px">Install a signed connector <code>.tgz</code> directly — handy for air-gapped environments. The bundle is always verified against the configured trust root before it is loaded.</p>
580
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
581
+ <input type="file" id="conn-upload-file" accept=".tgz,.tar.gz,application/octet-stream">
582
+ <button class="btn btn-primary btn-sm" onclick="uploadConnector(this)">Upload &amp; install</button>
583
+ </div>
584
+ </div>
585
+ </div>
586
+ </div>
587
+
552
588
  <!-- ===== Health ===== -->
553
589
  <div class="page" id="page-health">
554
590
  <div id="health-cards" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;">
@@ -744,12 +780,88 @@ function showPage(name) {
744
780
  document.querySelector(`.nav-btn[data-page="${name}"]`).classList.add('active');
745
781
  if(name==='settings') loadSettingsData();
746
782
  if(name==='health') loadHealthData();
783
+ if(name==='connectors') loadConnectors();
784
+ }
785
+
786
+ function escHtml(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
787
+
788
+ async function loadConnectors(){
789
+ const inst=document.getElementById('conn-installed');
790
+ const hub=document.getElementById('conn-hub');
791
+ try {
792
+ const d=await(await fetch('/api/connectors')).json();
793
+ const cs=(d.connectors||[]);
794
+ inst.innerHTML = cs.length ? cs.map(c=>{
795
+ const caps=Object.entries(c.capabilities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'—';
796
+ return `<div class="card" style="margin:0 0 10px">
797
+ <div class="card-header"><h2 style="font-size:15px">${escHtml(c.displayName)}
798
+ <span class="badge">${escHtml(c.source)}</span>
799
+ ${c.version?`<span class="badge">v${escHtml(c.version)}</span>`:''}</h2></div>
800
+ <div style="color:var(--text-muted);font-size:13px">${escHtml(c.description)||'<em>no description</em>'}</div>
801
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">type <code>${escHtml(c.name)}</code> · signals ${(c.signalTypes||[]).map(escHtml).join(', ')||'—'} · ${escHtml(caps)}</div>
802
+ </div>`;
803
+ }).join('') : '<div class="empty">No connectors loaded.</div>';
804
+ } catch(e){ inst.innerHTML='<div class="empty">Failed to load connectors.</div>'; }
805
+
806
+ try {
807
+ const d=await(await fetch('/api/hub/catalog')).json();
808
+ if(d.error){ hub.innerHTML=`<div class="empty">Hub catalog unreachable (${escHtml(d.url)}).<br>Set <code>HUB_CATALOG_URL</code> for a mirror. ${escHtml(d.error)}</div>`; return; }
809
+ const cs=(d.connectors||[]);
810
+ hub.innerHTML = cs.length ? cs.map(c=>{
811
+ const v=(c.versions&&c.versions[0])||{};
812
+ const ver=c.latest||v.version||'';
813
+ const status = c.installed
814
+ ? `<span class="badge badge-ok">installed${c.installedVersion?` v${escHtml(c.installedVersion)}`:''}</span>`
815
+ : (c.builtin?'<span class="badge">builtin</span>':'<span class="badge">available</span>');
816
+ const cmd = c.builtin
817
+ ? `# ${escHtml(c.displayName)} ships in the server image — no install needed.`
818
+ : `curl -fsSL -o plugin-signing.pub.pem https://raw.githubusercontent.com/ThoTischner/observability-mcp/main/docs/plugin-signing.pub.pem\nomcp plugin install ${escHtml(c.name)}@${escHtml(ver)} --trust-root plugin-signing.pub.pem`;
819
+ const id='ci-'+c.name;
820
+ return `<div class="card" style="margin:0 0 10px">
821
+ <div class="card-header"><h2 style="font-size:15px">${escHtml(c.displayName)}
822
+ <span class="badge">${escHtml(c.tier)}</span> ${status}</h2>
823
+ ${c.installed||c.builtin?'':`<span style="display:flex;gap:6px"><button class="btn btn-primary btn-sm" onclick="installConnector('${escHtml(c.name)}',this)">Install</button><button class="btn btn-ghost btn-sm" onclick="document.getElementById('${id}').classList.toggle('hidden')">CLI…</button></span>`}</div>
824
+ <div style="color:var(--text-muted);font-size:13px">${escHtml(c.description)}</div>
825
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">type <code>${escHtml(c.name)}</code> · signals ${(c.signalTypes||[]).map(escHtml).join(', ')||'—'} · latest <code>${escHtml(ver)||'—'}</code></div>
826
+ <pre id="${id}" class="hidden" style="margin-top:8px;background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;overflow:auto">${escHtml(cmd)}</pre>
827
+ </div>`;
828
+ }).join('') : '<div class="empty">Catalog empty.</div>';
829
+ } catch(e){ hub.innerHTML='<div class="empty">Failed to reach the hub catalog.</div>'; }
830
+ }
831
+
832
+ async function installConnector(name, btn){
833
+ if(btn){ btn.disabled=true; btn.textContent='Installing…'; }
834
+ try {
835
+ const r=await fetch('/api/connectors/install',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
836
+ const d=await r.json().catch(()=>({}));
837
+ if(r.ok){ toast(`Installed ${name}${d.version?(' v'+d.version):''}`); loadConnectors(); loadTypes(); }
838
+ else if(r.status===403){ toast('UI install disabled — use the CLI tab, or set ENABLE_UI_INSTALL=true + PLUGIN_TRUST_ROOT.'); if(btn){btn.disabled=false;btn.textContent='Install';} }
839
+ else { toast('Install failed: '+(d.error||r.status)); if(btn){btn.disabled=false;btn.textContent='Install';} }
840
+ } catch(e){ toast('Install error: '+e.message); if(btn){btn.disabled=false;btn.textContent='Install';} }
841
+ }
842
+ async function uploadConnector(btn){
843
+ const inp=document.getElementById('conn-upload-file');
844
+ const f=inp&&inp.files&&inp.files[0];
845
+ if(!f){ toast('Choose a connector .tgz first'); return; }
846
+ if(btn){ btn.disabled=true; btn.textContent='Uploading…'; }
847
+ try {
848
+ const r=await fetch('/api/connectors/upload',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:f});
849
+ const d=await r.json().catch(()=>({}));
850
+ if(r.ok){ toast(`Installed ${d.name||'connector'}${d.version?(' v'+d.version):''}`); inp.value=''; loadConnectors(); loadTypes(); }
851
+ else if(r.status===403){ toast('UI install disabled — set ENABLE_UI_INSTALL=true + PLUGIN_TRUST_ROOT.'); }
852
+ else { toast('Upload failed: '+(d.error||r.status)); }
853
+ } catch(e){ toast('Upload error: '+e.message); }
854
+ finally { if(btn){btn.disabled=false;btn.textContent='Upload & install';} }
747
855
  }
748
856
  function showTab(name) {
749
- document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
750
- document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
751
- document.getElementById('tab-'+name).classList.add('active');
752
- event.target.classList.add('active');
857
+ // Scope to the tab group's card so independent tab sets (Settings,
858
+ // Connectors) don't reset each other.
859
+ const scope = (event && event.target.closest('.card')) || document;
860
+ scope.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
861
+ scope.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
862
+ const el = document.getElementById('tab-'+name);
863
+ if (el) el.classList.add('active');
864
+ if (event && event.target) event.target.classList.add('active');
753
865
  if(name==='metrics') populateMetricsSourceSelect();
754
866
  }
755
867
  function closeModal(id) { document.getElementById(id).classList.remove('open'); }
@@ -1124,6 +1236,9 @@ async function loadInfo(){
1124
1236
  }catch{/* server too old or /api/info disabled */}
1125
1237
  }
1126
1238
 
1239
+ // Show the endpoint the user is actually reaching the server through
1240
+ // (localhost, a port-forward, or an ingress) — not a hardcoded host.
1241
+ document.getElementById('mcp-url').textContent = window.location.origin + '/mcp';
1127
1242
  (async()=>{await loadTypes();await refresh();await loadInfo();setInterval(refresh,15000);})();
1128
1243
  </script>
1129
1244
 
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
- "license": "MIT",
6
+ "license": "Apache-2.0",
7
7
  "mcpName": "io.github.ThoTischner/observability-mcp",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/ThoTischner/observability-mcp"
11
11
  },
12
+ "homepage": "https://github.com/ThoTischner/observability-mcp#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ThoTischner/observability-mcp/issues"
15
+ },
12
16
  "keywords": [
13
17
  "mcp",
14
18
  "observability",
@@ -18,7 +22,15 @@
18
22
  "anomaly-detection"
19
23
  ],
20
24
  "bin": {
21
- "observability-mcp": "./dist/index.js"
25
+ "observability-mcp": "./dist/index.js",
26
+ "omcp": "./dist/cli/index.js"
27
+ },
28
+ "exports": {
29
+ ".": "./dist/index.js",
30
+ "./analysis": {
31
+ "types": "./dist/analysis/index.d.ts",
32
+ "default": "./dist/analysis/index.js"
33
+ }
22
34
  },
23
35
  "files": [
24
36
  "dist",
@@ -26,12 +38,13 @@
26
38
  ],
27
39
  "scripts": {
28
40
  "dev": "tsx watch src/index.ts",
29
- "build": "tsc && cp -r src/ui dist/ui",
41
+ "build": "tsc && rm -rf dist/ui && cp -r src/ui dist/ui",
30
42
  "start": "node dist/index.js"
31
43
  },
32
44
  "dependencies": {
33
45
  "@modelcontextprotocol/sdk": "^1.12.0",
34
46
  "express": "^5.2.1",
47
+ "express-rate-limit": "^7.5.0",
35
48
  "js-yaml": "^4.1.0",
36
49
  "prom-client": "^15.1.0",
37
50
  "zod": "^4.4.3"
@@ -41,7 +54,7 @@
41
54
  "@types/js-yaml": "^4.0.9",
42
55
  "@types/node": "^25.7.0",
43
56
  "openapi-types": "^12.1.3",
44
- "tsx": "^4.19.0",
57
+ "tsx": "^4.22.0",
45
58
  "typescript": "^6.0.3"
46
59
  },
47
60
  "overrides": {