@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.
- package/dist/analysis/anomaly.d.ts +89 -0
- package/dist/analysis/anomaly.js +235 -0
- package/dist/analysis/anomaly.test.js +149 -1
- package/dist/analysis/backtest.d.ts +31 -0
- package/dist/analysis/backtest.js +206 -0
- package/dist/analysis/backtest.test.d.ts +1 -0
- package/dist/analysis/backtest.test.js +34 -0
- package/dist/analysis/correlator.d.ts +35 -0
- package/dist/analysis/correlator.js +95 -0
- package/dist/analysis/correlator.test.js +60 -1
- package/dist/analysis/health.d.ts +2 -3
- package/dist/analysis/index.d.ts +32 -0
- package/dist/analysis/index.js +29 -0
- package/dist/analysis/library.test.d.ts +1 -0
- package/dist/analysis/library.test.js +44 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +76 -0
- package/dist/auth/credentials.test.d.ts +1 -0
- package/dist/auth/credentials.test.js +57 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +370 -0
- package/dist/cli/lib.d.ts +95 -0
- package/dist/cli/lib.js +185 -0
- package/dist/cli/lib.test.d.ts +1 -0
- package/dist/cli/lib.test.js +134 -0
- package/dist/connectors/hub.d.ts +48 -0
- package/dist/connectors/hub.js +51 -0
- package/dist/connectors/hub.test.d.ts +1 -0
- package/dist/connectors/hub.test.js +52 -0
- package/dist/connectors/install.d.ts +24 -0
- package/dist/connectors/install.js +100 -0
- package/dist/connectors/install.test.d.ts +1 -0
- package/dist/connectors/install.test.js +58 -0
- package/dist/connectors/loader.d.ts +5 -0
- package/dist/connectors/loader.js +54 -2
- package/dist/connectors/loki.js +11 -4
- package/dist/connectors/loki.test.js +27 -0
- package/dist/connectors/verify.d.ts +19 -0
- package/dist/connectors/verify.js +87 -0
- package/dist/connectors/verify.test.d.ts +1 -0
- package/dist/connectors/verify.test.js +63 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +18 -0
- package/dist/index.js +322 -34
- package/dist/net/egress-policy.d.ts +31 -0
- package/dist/net/egress-policy.js +37 -0
- package/dist/net/egress-policy.test.d.ts +1 -0
- package/dist/net/egress-policy.test.js +52 -0
- package/dist/sdk/index.d.ts +6 -0
- package/dist/sdk/manifest-schema.d.ts +1 -0
- package/dist/sdk/manifest-schema.js +11 -0
- package/dist/tools/context-seam.test.d.ts +1 -0
- package/dist/tools/context-seam.test.js +23 -0
- package/dist/tools/detect-anomalies.d.ts +2 -1
- package/dist/tools/detect-anomalies.js +47 -11
- package/dist/tools/get-service-health.d.ts +2 -1
- package/dist/tools/get-service-health.js +2 -1
- package/dist/tools/handlers.test.js +73 -0
- package/dist/tools/list-services.d.ts +2 -1
- package/dist/tools/list-services.js +2 -1
- package/dist/tools/list-sources.d.ts +2 -1
- package/dist/tools/list-sources.js +2 -1
- package/dist/tools/query-logs.d.ts +2 -1
- package/dist/tools/query-logs.js +2 -1
- package/dist/tools/query-metrics.d.ts +2 -1
- package/dist/tools/query-metrics.js +9 -1
- package/dist/ui/index.html +119 -4
- 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 {
|
|
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
|
-
|
|
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
|
|
60
|
-
const anomaly =
|
|
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.
|
|
71
|
+
const deviationPercent = anomaly.baselineValue === 0
|
|
63
72
|
? 100
|
|
64
|
-
: Math.round(((anomaly.
|
|
73
|
+
: Math.round(((anomaly.recentValue - anomaly.baselineValue) / anomaly.baselineValue) * 100);
|
|
65
74
|
allAnomalies.push({
|
|
66
75
|
metric,
|
|
67
|
-
severity: Math.abs(anomaly.
|
|
68
|
-
description: `${metric}
|
|
69
|
-
currentValue: anomaly.
|
|
70
|
-
baselineValue: anomaly.
|
|
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;
|
package/dist/tools/query-logs.js
CHANGED
|
@@ -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);
|
package/dist/ui/index.html
CHANGED
|
@@ -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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
57
|
+
"tsx": "^4.22.0",
|
|
45
58
|
"typescript": "^6.0.3"
|
|
46
59
|
},
|
|
47
60
|
"overrides": {
|