@thotischner/observability-mcp 1.4.0 → 1.4.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/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/index.js +301 -22
- 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/ui/index.html +119 -4
- package/package.json +9 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
export declare class PluginVerificationError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
|
|
5
|
+
export declare function loadTrustRoot(pemPath: string): KeyObject;
|
|
6
|
+
/** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
|
|
7
|
+
export declare function sha256Integrity(data: Buffer): string;
|
|
8
|
+
/**
|
|
9
|
+
* Constant-time-ish compare of the entry file against the manifest's
|
|
10
|
+
* declared integrity digest. Throws on mismatch.
|
|
11
|
+
*/
|
|
12
|
+
export declare function verifyIntegrity(entryPath: string, integrity: string | undefined): void;
|
|
13
|
+
/**
|
|
14
|
+
* Verify a detached signature over the raw manifest bytes against the
|
|
15
|
+
* trust root. Signature is read as base64 (whitespace tolerated, e.g. a
|
|
16
|
+
* `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
|
|
17
|
+
* Throws PluginVerificationError if the signature does not verify.
|
|
18
|
+
*/
|
|
19
|
+
export declare function verifyManifestSignature(manifestBytes: Buffer, signatureBytes: Buffer, trustRoot: KeyObject): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
// Airgapped-friendly plugin verification.
|
|
4
|
+
//
|
|
5
|
+
// No network, no external binary (no cosign): a plugin is trusted when
|
|
6
|
+
// 1. its entry file hashes to the manifest's `integrity` digest, and
|
|
7
|
+
// 2. the manifest bytes carry a detached signature that verifies
|
|
8
|
+
// against a locally-configured trust-root public key.
|
|
9
|
+
//
|
|
10
|
+
// Ed25519 and RSA/EC (PKCS#8 / SPKI PEM) trust roots are supported.
|
|
11
|
+
export class PluginVerificationError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
|
|
14
|
+
export function loadTrustRoot(pemPath) {
|
|
15
|
+
let pem;
|
|
16
|
+
try {
|
|
17
|
+
pem = readFileSync(pemPath, "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
throw new PluginVerificationError(`trust root unreadable at ${pemPath}: ${String(err)}`);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return createPublicKey(pem);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
throw new PluginVerificationError(`trust root is not a valid PEM public key: ${String(err)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
|
|
30
|
+
export function sha256Integrity(data) {
|
|
31
|
+
return "sha256-" + createHash("sha256").update(data).digest("base64");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Constant-time-ish compare of the entry file against the manifest's
|
|
35
|
+
* declared integrity digest. Throws on mismatch.
|
|
36
|
+
*/
|
|
37
|
+
export function verifyIntegrity(entryPath, integrity) {
|
|
38
|
+
if (!integrity) {
|
|
39
|
+
throw new PluginVerificationError("manifest has no integrity digest");
|
|
40
|
+
}
|
|
41
|
+
let bytes;
|
|
42
|
+
try {
|
|
43
|
+
bytes = readFileSync(entryPath);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new PluginVerificationError(`entry file unreadable: ${String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
const actual = sha256Integrity(bytes);
|
|
49
|
+
if (actual.length !== integrity.length || actual !== integrity) {
|
|
50
|
+
throw new PluginVerificationError(`entry file integrity mismatch (manifest=${integrity} actual=${actual})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Verify a detached signature over the raw manifest bytes against the
|
|
55
|
+
* trust root. Signature is read as base64 (whitespace tolerated, e.g. a
|
|
56
|
+
* `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
|
|
57
|
+
* Throws PluginVerificationError if the signature does not verify.
|
|
58
|
+
*/
|
|
59
|
+
export function verifyManifestSignature(manifestBytes, signatureBytes, trustRoot) {
|
|
60
|
+
const sig = decodeSignature(signatureBytes);
|
|
61
|
+
// Ed25519/Ed448 take algorithm=null; RSA/EC sign over a SHA-256 digest.
|
|
62
|
+
const keyType = trustRoot.asymmetricKeyType;
|
|
63
|
+
const algorithm = keyType === "ed25519" || keyType === "ed448" ? null : "sha256";
|
|
64
|
+
let ok = false;
|
|
65
|
+
try {
|
|
66
|
+
ok = cryptoVerify(algorithm, manifestBytes, trustRoot, sig);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new PluginVerificationError(`signature verification errored: ${String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!ok) {
|
|
72
|
+
throw new PluginVerificationError("manifest signature does not match trust root");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Accept either raw DER bytes or a base64/armored .sig file.
|
|
76
|
+
function decodeSignature(raw) {
|
|
77
|
+
const text = raw.toString("utf8").trim();
|
|
78
|
+
if (/^[A-Za-z0-9+/\s=]+$/.test(text) && text.length > 0) {
|
|
79
|
+
const compact = text.replace(/\s+/g, "");
|
|
80
|
+
const b = Buffer.from(compact, "base64");
|
|
81
|
+
// Round-trip check: if it re-encodes cleanly it was really base64.
|
|
82
|
+
if (b.length > 0 && b.toString("base64").replace(/=+$/, "") === compact.replace(/=+$/, "")) {
|
|
83
|
+
return b;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generateKeyPairSync, sign as cryptoSign, createPublicKey } from "node:crypto";
|
|
4
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { sha256Integrity, verifyIntegrity, verifyManifestSignature, loadTrustRoot, PluginVerificationError, } from "./verify.js";
|
|
8
|
+
function tmp() {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), "verify-"));
|
|
10
|
+
}
|
|
11
|
+
test("sha256Integrity produces sha256-<base64> and round-trips", () => {
|
|
12
|
+
const digest = sha256Integrity(Buffer.from("hello"));
|
|
13
|
+
assert.match(digest, /^sha256-[A-Za-z0-9+/]+=*$/);
|
|
14
|
+
assert.equal(sha256Integrity(Buffer.from("hello")), digest);
|
|
15
|
+
assert.notEqual(sha256Integrity(Buffer.from("hellp")), digest);
|
|
16
|
+
});
|
|
17
|
+
test("verifyIntegrity passes on match, throws on mismatch and missing", () => {
|
|
18
|
+
const dir = tmp();
|
|
19
|
+
const entry = join(dir, "index.js");
|
|
20
|
+
writeFileSync(entry, "export default () => ({});\n");
|
|
21
|
+
const good = sha256Integrity(Buffer.from("export default () => ({});\n"));
|
|
22
|
+
assert.doesNotThrow(() => verifyIntegrity(entry, good));
|
|
23
|
+
assert.throws(() => verifyIntegrity(entry, "sha256-AAAA"), PluginVerificationError);
|
|
24
|
+
assert.throws(() => verifyIntegrity(entry, undefined), PluginVerificationError);
|
|
25
|
+
assert.throws(() => verifyIntegrity(join(dir, "nope.js"), good), PluginVerificationError);
|
|
26
|
+
});
|
|
27
|
+
test("Ed25519: valid signature verifies, tampered manifest is rejected", () => {
|
|
28
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
29
|
+
const manifest = Buffer.from('{"name":"prometheus","schemaVersion":1}');
|
|
30
|
+
const sig = cryptoSign(null, manifest, privateKey);
|
|
31
|
+
assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
|
|
32
|
+
// base64-armored signature (the form `openssl ... | base64` emits)
|
|
33
|
+
const armored = Buffer.from(sig.toString("base64"));
|
|
34
|
+
assert.doesNotThrow(() => verifyManifestSignature(manifest, armored, publicKey));
|
|
35
|
+
// tampered bytes
|
|
36
|
+
assert.throws(() => verifyManifestSignature(Buffer.from('{"name":"evil"}'), sig, publicKey), PluginVerificationError);
|
|
37
|
+
});
|
|
38
|
+
test("RSA trust root path (algorithm=sha256) verifies", () => {
|
|
39
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
40
|
+
const manifest = Buffer.from('{"name":"loki"}');
|
|
41
|
+
const sig = cryptoSign("sha256", manifest, privateKey);
|
|
42
|
+
assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
|
|
43
|
+
});
|
|
44
|
+
test("signature from a different key is rejected", () => {
|
|
45
|
+
const a = generateKeyPairSync("ed25519");
|
|
46
|
+
const b = generateKeyPairSync("ed25519");
|
|
47
|
+
const manifest = Buffer.from("payload");
|
|
48
|
+
const sig = cryptoSign(null, manifest, a.privateKey);
|
|
49
|
+
assert.throws(() => verifyManifestSignature(manifest, sig, b.publicKey), PluginVerificationError);
|
|
50
|
+
});
|
|
51
|
+
test("loadTrustRoot parses PEM and rejects garbage", () => {
|
|
52
|
+
const dir = tmp();
|
|
53
|
+
const { publicKey } = generateKeyPairSync("ed25519");
|
|
54
|
+
const pem = publicKey.export({ type: "spki", format: "pem" });
|
|
55
|
+
const p = join(dir, "trust.pem");
|
|
56
|
+
writeFileSync(p, pem);
|
|
57
|
+
const loaded = loadTrustRoot(p);
|
|
58
|
+
assert.equal(loaded.export({ type: "spki", format: "pem" }), createPublicKey(pem).export({ type: "spki", format: "pem" }));
|
|
59
|
+
const bad = join(dir, "bad.pem");
|
|
60
|
+
writeFileSync(bad, "not a key");
|
|
61
|
+
assert.throws(() => loadTrustRoot(bad), PluginVerificationError);
|
|
62
|
+
assert.throws(() => loadTrustRoot(join(dir, "missing.pem")), PluginVerificationError);
|
|
63
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -3,10 +3,14 @@ import express from "express";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
|
|
8
9
|
import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
|
|
9
10
|
import { getPluginLoader } from "./connectors/loader.js";
|
|
11
|
+
import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, } from "./connectors/hub.js";
|
|
12
|
+
import { isValidConnectorName, installTarball } from "./connectors/install.js";
|
|
13
|
+
import { PluginVerificationError } from "./connectors/verify.js";
|
|
10
14
|
import { selfRegistry, withToolMetrics, apiRequests, mcpActiveSessions } from "./metrics/self.js";
|
|
11
15
|
import { buildOpenApiSpec } from "./openapi.js";
|
|
12
16
|
import { listSourcesHandler } from "./tools/list-sources.js";
|
|
@@ -17,7 +21,8 @@ import { getServiceHealthHandler, setHealthThresholds } from "./tools/get-servic
|
|
|
17
21
|
import { detectAnomaliesHandler } from "./tools/detect-anomalies.js";
|
|
18
22
|
import { fileURLToPath } from "node:url";
|
|
19
23
|
import { dirname, join } from "node:path";
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
24
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
21
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
27
|
// Read once at startup; the file is shipped inside the image so this
|
|
23
28
|
// is the source of truth even when the user runs from `npx`.
|
|
@@ -67,7 +72,49 @@ function validateSourceUrl(url) {
|
|
|
67
72
|
return `Invalid URL: "${url}"`;
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
// Hard cap for a downloaded/uploaded connector tarball (defence against
|
|
76
|
+
// a hostile or accidental huge artifact OOM-ing the server).
|
|
77
|
+
const MAX_CONNECTOR_TGZ_BYTES = 64 * 1024 * 1024;
|
|
78
|
+
// Dependency-free fixed-window per-client rate limiter for the runtime
|
|
79
|
+
// connector install/upload routes (expensive: fetch + extract + verify +
|
|
80
|
+
// fs write + loader rescan). Bounds abuse even with ENABLE_UI_INSTALL on.
|
|
81
|
+
const installRateState = new Map();
|
|
82
|
+
function installRateLimit(req, res, next) {
|
|
83
|
+
const WINDOW_MS = 60_000;
|
|
84
|
+
const MAX = 5;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
if (installRateState.size > 5000) {
|
|
87
|
+
for (const [k, v] of installRateState)
|
|
88
|
+
if (v.resetAt < now)
|
|
89
|
+
installRateState.delete(k);
|
|
90
|
+
}
|
|
91
|
+
const key = req.ip || "unknown";
|
|
92
|
+
let s = installRateState.get(key);
|
|
93
|
+
if (!s || s.resetAt < now) {
|
|
94
|
+
s = { count: 0, resetAt: now + WINDOW_MS };
|
|
95
|
+
installRateState.set(key, s);
|
|
96
|
+
}
|
|
97
|
+
s.count++;
|
|
98
|
+
if (s.count > MAX) {
|
|
99
|
+
res.setHeader("Retry-After", String(Math.ceil((s.resetAt - now) / 1000)));
|
|
100
|
+
res.status(429).json({
|
|
101
|
+
error: "rate limit exceeded — too many connector install attempts, slow down",
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
next();
|
|
106
|
+
}
|
|
70
107
|
async function main() {
|
|
108
|
+
// Stdio transport mode (MCP catalogs / desktop clients / Glama's
|
|
109
|
+
// mcp-proxy spawn a stdio MCP server and read JSON-RPC from stdout).
|
|
110
|
+
// The protocol stream MUST be the only thing on stdout, so route all
|
|
111
|
+
// console.log to stderr before anything logs.
|
|
112
|
+
const STDIO = process.argv.includes("--stdio") ||
|
|
113
|
+
process.env.MCP_TRANSPORT === "stdio" ||
|
|
114
|
+
!!process.env.MCP_STDIO;
|
|
115
|
+
if (STDIO) {
|
|
116
|
+
console.log = (...a) => console.error(...a);
|
|
117
|
+
}
|
|
71
118
|
let config = loadConfig();
|
|
72
119
|
await getPluginLoader().load();
|
|
73
120
|
const registry = new ConnectorRegistry();
|
|
@@ -83,32 +130,108 @@ async function main() {
|
|
|
83
130
|
version: SERVER_VERSION,
|
|
84
131
|
});
|
|
85
132
|
// --- Register tools with Zod schemas ---
|
|
86
|
-
mcpServer.tool("list_sources",
|
|
87
|
-
|
|
133
|
+
mcpServer.tool("list_sources", [
|
|
134
|
+
"List the configured observability backends (Prometheus, Loki, and any connector) and whether each is currently reachable.",
|
|
135
|
+
"When to use: call this first to learn which source names exist and are healthy before passing `source` to other tools, or to debug why a query returns no data.",
|
|
136
|
+
"Behavior: read-only, no side effects. Returns one entry per source with its name, type, configured URL, signal types (metrics/logs), and a live up/down status. Never throws for an unreachable backend — the backend is reported as down instead.",
|
|
137
|
+
"Related: use `list_services` to see what is monitored within these sources.",
|
|
138
|
+
].join(" "), {}, async () => withToolMetrics("list_sources", () => listSourcesHandler(registry)));
|
|
139
|
+
mcpServer.tool("list_services", [
|
|
140
|
+
"Discover the service names that can be queried, aggregated across every connected backend.",
|
|
141
|
+
"When to use: call this before `query_metrics`, `query_logs`, or `get_service_health` to obtain the exact, case-sensitive service name those tools require.",
|
|
142
|
+
"Behavior: read-only, no side effects. Returns one entry per service with the service name, the source(s) it was discovered in, and which signals are available for it (metrics, logs, or both).",
|
|
143
|
+
"Related: `list_sources` for backend health; `get_service_health` for a per-service overview.",
|
|
144
|
+
].join(" "), {
|
|
145
|
+
filter: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe("Optional case-insensitive substring to narrow the result to matching service names (e.g. 'payment'). Omit to list every discovered service."),
|
|
149
|
+
}, async (args) => withToolMetrics("list_services", () => listServicesHandler(registry, args)));
|
|
88
150
|
const metricsList = getAvailableMetricNames(registry);
|
|
89
151
|
const metricNames = registry.getBySignal("metrics").flatMap(c => c.getMetrics().map(m => m.name));
|
|
90
152
|
const uniqueNames = [...new Set(metricNames)];
|
|
91
|
-
mcpServer.tool("query_metrics",
|
|
92
|
-
service
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
153
|
+
mcpServer.tool("query_metrics", [
|
|
154
|
+
"Fetch the raw time-series for ONE metric of ONE service over a look-back window, returned together with pre-computed summary statistics.",
|
|
155
|
+
"When to use: when you need the actual numeric values or the trend of a known metric. For a 'is this service OK?' verdict use `get_service_health`; to find which services are misbehaving use `detect_anomalies`.",
|
|
156
|
+
"Prerequisites: get the exact service name from `list_services` and choose a metric from the list at the end of this description.",
|
|
157
|
+
"Behavior: read-only, no side effects. Returns an ordered array of {timestamp, value} points plus a summary {current, average, min, max, trend}. With `groupBy` set, returns one labelled series per distinct label value under `groups` instead of a single aggregated series. Units depend on the metric (e.g. CPU as %, latency as ms, rates as per-second). An unknown service/metric or an unreachable backend yields a structured explanatory error, never an exception.",
|
|
158
|
+
`Available metrics: ${metricsList}`,
|
|
159
|
+
].join(" "), {
|
|
160
|
+
service: z
|
|
161
|
+
.string()
|
|
162
|
+
.describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'api-gateway', 'payment-service')."),
|
|
163
|
+
metric: z
|
|
164
|
+
.string()
|
|
165
|
+
.describe(`Required. Exact metric name to query. One of: ${uniqueNames.join(", ")}.`),
|
|
166
|
+
duration: z
|
|
167
|
+
.string()
|
|
168
|
+
.optional()
|
|
169
|
+
.describe("Optional. Look-back window ending at 'now', written as <number><unit> with unit s|m|h|d (e.g. '5m', '90m', '1h', '24h'). Default: '5m'."),
|
|
170
|
+
source: z
|
|
171
|
+
.string()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe("Optional. Restrict the query to a single backend by its source name (see `list_sources`). Default: query and merge all metrics backends."),
|
|
174
|
+
groupBy: z
|
|
175
|
+
.string()
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Optional. Metric label to break the result down by, e.g. 'instance', 'pod', 'node'. When set, the response contains one series per distinct label value under `groups`. Default: a single aggregated series."),
|
|
97
178
|
}, async (args) => withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args)));
|
|
98
|
-
mcpServer.tool("query_logs",
|
|
99
|
-
service
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
mcpServer.tool("query_logs", [
|
|
180
|
+
"Fetch recent log entries for ONE service over a look-back window, with a pre-computed summary (error/warning counts and the most frequent error patterns).",
|
|
181
|
+
"When to use: to inspect what a service actually logged, or to investigate an error spike surfaced by `detect_anomalies` / `get_service_health`. For numeric metrics use `query_metrics` instead.",
|
|
182
|
+
"Prerequisites: get the exact service name from `list_services` (the service must expose a logs signal).",
|
|
183
|
+
"Behavior: read-only, no side effects. Returns the matching log entries (newest first, capped by `limit`) plus a summary with total/error/warn counts and top recurring error patterns. No matches yields an empty result with a zeroed summary; an unreachable backend yields a structured explanatory error, never an exception.",
|
|
184
|
+
].join(" "), {
|
|
185
|
+
service: z
|
|
186
|
+
.string()
|
|
187
|
+
.describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'payment-service')."),
|
|
188
|
+
query: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("Optional. Filter expression matched against the log message; regular expressions are supported. Omit to return all entries in the window."),
|
|
192
|
+
duration: z
|
|
193
|
+
.string()
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("Optional. Look-back window ending at 'now', written as <number><unit> with unit s|m|h|d (e.g. '5m', '1h', '24h'). Default: '5m'."),
|
|
196
|
+
level: z
|
|
197
|
+
.enum(["error", "warn", "info", "debug"])
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Optional. Return only entries at this severity. Default: all levels."),
|
|
200
|
+
limit: z
|
|
201
|
+
.number()
|
|
202
|
+
.int()
|
|
203
|
+
.positive()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe("Optional. Maximum number of log entries to return (most recent first). Default: 100."),
|
|
104
206
|
}, async (args) => withToolMetrics("query_logs", () => queryLogsHandler(registry, args)));
|
|
105
|
-
mcpServer.tool("get_service_health",
|
|
106
|
-
|
|
207
|
+
mcpServer.tool("get_service_health", [
|
|
208
|
+
"Produce a single aggregated health verdict for ONE service by combining its metrics and logs.",
|
|
209
|
+
"When to use: the fastest way to answer 'is this service healthy right now and why?'. Use `query_metrics`/`query_logs` to drill into the underlying numbers, or `detect_anomalies` to scan many services at once.",
|
|
210
|
+
"Prerequisites: get the exact service name from `list_services`.",
|
|
211
|
+
"Behavior: read-only, no side effects. Returns a weighted health score (0–100), a status of healthy | degraded | critical, the key contributing metrics, a log error summary, detected anomalies, and cross-signal correlations explaining the score. A service with no data yields an explanatory result rather than an exception.",
|
|
212
|
+
].join(" "), {
|
|
213
|
+
service: z
|
|
214
|
+
.string()
|
|
215
|
+
.describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'payment-service')."),
|
|
107
216
|
}, async (args) => withToolMetrics("get_service_health", () => getServiceHealthHandler(registry, args)));
|
|
108
|
-
mcpServer.tool("detect_anomalies",
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
mcpServer.tool("detect_anomalies", [
|
|
218
|
+
"Scan one or all monitored services for abnormal behavior and return the findings ranked by severity.",
|
|
219
|
+
"When to use: the entry point for 'is anything wrong anywhere?' triage. Once a service is flagged, follow up with `get_service_health` for the verdict or `query_metrics`/`query_logs` for the raw evidence.",
|
|
220
|
+
"Behavior: read-only, no side effects. Applies z-score analysis to metrics, detects log error-rate spikes, and correlates the two. Returns a list of anomalies, each with the affected service, metric/signal, severity, the deviation (e.g. σ and % change), and a short explanation. No anomalies yields an empty list, not an error.",
|
|
221
|
+
"Related: `get_service_health` (single-service verdict), `query_metrics` (raw series behind a flagged metric).",
|
|
222
|
+
].join(" "), {
|
|
223
|
+
service: z
|
|
224
|
+
.string()
|
|
225
|
+
.optional()
|
|
226
|
+
.describe("Optional. Restrict the scan to one service (exact, case-sensitive name from `list_services`). Default: scan every monitored service."),
|
|
227
|
+
duration: z
|
|
228
|
+
.string()
|
|
229
|
+
.optional()
|
|
230
|
+
.describe("Optional. Look-back window analyzed for anomalies, written as <number><unit> with unit s|m|h|d (e.g. '5m', '15m', '1h'). Default: '10m'."),
|
|
231
|
+
sensitivity: z
|
|
232
|
+
.enum(["low", "medium", "high"])
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Optional. Detection threshold: 'low' flags only strong deviations (>3σ), 'medium' is balanced (>2σ), 'high' is most sensitive and noisier (>1.5σ). Default: 'medium'."),
|
|
112
235
|
}, async (args) => withToolMetrics("detect_anomalies", () => detectAnomaliesHandler(registry, args)));
|
|
113
236
|
return mcpServer;
|
|
114
237
|
}
|
|
@@ -116,11 +239,18 @@ async function main() {
|
|
|
116
239
|
const app = express();
|
|
117
240
|
app.use(express.json({ limit: "1mb" }));
|
|
118
241
|
// Security headers
|
|
119
|
-
app.use((
|
|
242
|
+
app.use((req, res, next) => {
|
|
120
243
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
121
244
|
res.setHeader("X-Frame-Options", "DENY");
|
|
122
245
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
123
246
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
247
|
+
// Dynamic API responses must never be served from the browser/proxy
|
|
248
|
+
// cache: after a mutation (e.g. installing a connector) the UI
|
|
249
|
+
// re-fetches these GETs immediately, and a heuristically-cached stale
|
|
250
|
+
// body would make the change "not show up until a page reload".
|
|
251
|
+
if (req.path.startsWith("/api/")) {
|
|
252
|
+
res.setHeader("Cache-Control", "no-store");
|
|
253
|
+
}
|
|
124
254
|
next();
|
|
125
255
|
});
|
|
126
256
|
// API request counter — emitted at response time so the `status` label
|
|
@@ -215,6 +345,145 @@ async function main() {
|
|
|
215
345
|
})),
|
|
216
346
|
});
|
|
217
347
|
});
|
|
348
|
+
// Connectors currently loaded into this server (builtin + filesystem
|
|
349
|
+
// plugins), with manifest metadata — drives the UI "Connectors" page.
|
|
350
|
+
app.get("/api/connectors", (_req, res) => {
|
|
351
|
+
res.json({ connectors: describeInstalled(getPluginLoader().list()) });
|
|
352
|
+
});
|
|
353
|
+
// Server-side proxy of the connector hub catalog (so the browser
|
|
354
|
+
// needn't reach the hub directly — works behind a proxy / against a
|
|
355
|
+
// mirror via HUB_CATALOG_URL). Installed status merged in.
|
|
356
|
+
app.get("/api/hub/catalog", async (_req, res) => {
|
|
357
|
+
const url = resolveHubCatalogUrl();
|
|
358
|
+
try {
|
|
359
|
+
const catalog = await fetchHubCatalog(url);
|
|
360
|
+
res.json({
|
|
361
|
+
url,
|
|
362
|
+
connectors: mergeCatalog(catalog, describeInstalled(getPluginLoader().list())),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
res.status(502).json({ url, error: e instanceof Error ? e.message : String(e), connectors: [] });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// Install a connector from the hub into the running server.
|
|
370
|
+
//
|
|
371
|
+
// Runtime code-load is powerful, so this is doubly gated:
|
|
372
|
+
// 1. ENABLE_UI_INSTALL=true must be set (default OFF).
|
|
373
|
+
// 2. PLUGIN_TRUST_ROOT must be configured — install is ALWAYS
|
|
374
|
+
// fail-closed verified (no insecure bypass over HTTP).
|
|
375
|
+
// Only catalog tarballUrls are fetched (no arbitrary URL in the body)
|
|
376
|
+
// to avoid SSRF. The connector persists to PLUGINS_DIR (back it with
|
|
377
|
+
// a PVC on k8s so it survives restarts).
|
|
378
|
+
app.post("/api/connectors/install", installRateLimit, async (req, res) => {
|
|
379
|
+
if (process.env.ENABLE_UI_INSTALL !== "true") {
|
|
380
|
+
return res.status(403).json({
|
|
381
|
+
error: "UI install is disabled. Set ENABLE_UI_INSTALL=true and PLUGIN_TRUST_ROOT to enable it.",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const trustRootPath = process.env.PLUGIN_TRUST_ROOT;
|
|
385
|
+
if (!trustRootPath) {
|
|
386
|
+
return res.status(412).json({
|
|
387
|
+
error: "PLUGIN_TRUST_ROOT not configured — refusing to install unverified code.",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const name = (req.body || {}).name;
|
|
391
|
+
const version = (req.body || {}).version;
|
|
392
|
+
if (!isValidConnectorName(name)) {
|
|
393
|
+
return res.status(400).json({ error: "invalid connector name" });
|
|
394
|
+
}
|
|
395
|
+
const pluginsDir = process.env.PLUGINS_DIR ?? "/app/plugins";
|
|
396
|
+
let work = null;
|
|
397
|
+
try {
|
|
398
|
+
const catalog = await fetchHubCatalog(resolveHubCatalogUrl());
|
|
399
|
+
const entry = catalog.connectors.find((c) => c.name === name);
|
|
400
|
+
if (!entry)
|
|
401
|
+
return res.status(404).json({ error: `'${name}' is not in the catalog` });
|
|
402
|
+
if (entry.builtin)
|
|
403
|
+
return res.status(409).json({ error: `'${name}' is builtin — no install needed` });
|
|
404
|
+
const v = version
|
|
405
|
+
? entry.versions.find((x) => x.version === version)
|
|
406
|
+
: entry.versions.find((x) => x.version === (entry.latest ?? entry.versions[0]?.version)) ?? entry.versions[0];
|
|
407
|
+
if (!v || !v.tarballUrl) {
|
|
408
|
+
return res.status(422).json({ error: `no tarball for ${name}@${version ?? "latest"}` });
|
|
409
|
+
}
|
|
410
|
+
const resp = await fetch(v.tarballUrl);
|
|
411
|
+
if (!resp.ok)
|
|
412
|
+
return res.status(502).json({ error: `tarball download HTTP ${resp.status}` });
|
|
413
|
+
const declared = Number(resp.headers.get("content-length") || 0);
|
|
414
|
+
if (declared > MAX_CONNECTOR_TGZ_BYTES) {
|
|
415
|
+
return res.status(413).json({ error: `tarball too large (${declared} bytes)` });
|
|
416
|
+
}
|
|
417
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
418
|
+
if (buf.length > MAX_CONNECTOR_TGZ_BYTES) {
|
|
419
|
+
return res.status(413).json({ error: `tarball too large (${buf.length} bytes)` });
|
|
420
|
+
}
|
|
421
|
+
work = mkdtempSync(join(tmpdir(), "obsmcp-dl-"));
|
|
422
|
+
const tgz = join(work, "c.tgz");
|
|
423
|
+
writeFileSync(tgz, buf);
|
|
424
|
+
const result = installTarball({ tgzPath: tgz, pluginsDir, trustRootPath, expectedName: name });
|
|
425
|
+
await getPluginLoader().load(); // re-scan so /api/connectors reflects it
|
|
426
|
+
res.json({
|
|
427
|
+
ok: true,
|
|
428
|
+
...result,
|
|
429
|
+
note: "installed & persisted to PLUGINS_DIR. Add a source of this type to use it; a server restart is recommended for full availability in existing MCP sessions.",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
434
|
+
const code = e instanceof PluginVerificationError ? 400 : 500;
|
|
435
|
+
res.status(code).json({ error: `install failed (fail-closed): ${msg}` });
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
if (work)
|
|
439
|
+
rmSync(work, { recursive: true, force: true });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
// Upload a connector bundle (.tgz) and install it into the running
|
|
443
|
+
// server. Same fail-closed guardrails as /install: the upload is
|
|
444
|
+
// ALWAYS verified against PLUGIN_TRUST_ROOT (signature + integrity),
|
|
445
|
+
// so an unsigned/tampered bundle is rejected. Body is the raw tarball
|
|
446
|
+
// bytes (application/octet-stream). Persists to PLUGINS_DIR.
|
|
447
|
+
app.post("/api/connectors/upload", installRateLimit, express.raw({ type: "application/octet-stream", limit: "50mb" }), async (req, res) => {
|
|
448
|
+
if (process.env.ENABLE_UI_INSTALL !== "true") {
|
|
449
|
+
return res.status(403).json({
|
|
450
|
+
error: "UI install is disabled. Set ENABLE_UI_INSTALL=true and PLUGIN_TRUST_ROOT to enable it.",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const trustRootPath = process.env.PLUGIN_TRUST_ROOT;
|
|
454
|
+
if (!trustRootPath) {
|
|
455
|
+
return res.status(412).json({
|
|
456
|
+
error: "PLUGIN_TRUST_ROOT not configured — refusing to install unverified code.",
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const body = req.body;
|
|
460
|
+
if (!Buffer.isBuffer(body) || body.length === 0) {
|
|
461
|
+
return res.status(400).json({ error: "empty body — POST the connector .tgz as application/octet-stream" });
|
|
462
|
+
}
|
|
463
|
+
const pluginsDir = process.env.PLUGINS_DIR ?? "/app/plugins";
|
|
464
|
+
let work = null;
|
|
465
|
+
try {
|
|
466
|
+
work = mkdtempSync(join(tmpdir(), "obsmcp-up-"));
|
|
467
|
+
const tgz = join(work, "c.tgz");
|
|
468
|
+
writeFileSync(tgz, body);
|
|
469
|
+
const result = installTarball({ tgzPath: tgz, pluginsDir, trustRootPath });
|
|
470
|
+
await getPluginLoader().load(); // re-scan so /api/connectors reflects it
|
|
471
|
+
res.json({
|
|
472
|
+
ok: true,
|
|
473
|
+
...result,
|
|
474
|
+
note: "uploaded, verified & persisted to PLUGINS_DIR. Add a source of this type to use it; a server restart is recommended for full availability in existing MCP sessions.",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
479
|
+
const code = e instanceof PluginVerificationError ? 400 : 500;
|
|
480
|
+
res.status(code).json({ error: `upload install failed (fail-closed): ${msg}` });
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
if (work)
|
|
484
|
+
rmSync(work, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
218
487
|
// Add a new source
|
|
219
488
|
app.post("/api/sources", async (req, res) => {
|
|
220
489
|
const { name, type, url, enabled, auth, tls } = req.body;
|
|
@@ -432,6 +701,16 @@ async function main() {
|
|
|
432
701
|
saveConfig(config);
|
|
433
702
|
res.json({ ok: true });
|
|
434
703
|
});
|
|
704
|
+
// Stdio transport: one server over stdin/stdout, no HTTP listener.
|
|
705
|
+
if (STDIO) {
|
|
706
|
+
const server = createMcpServer();
|
|
707
|
+
await server.connect(new StdioServerTransport());
|
|
708
|
+
console.error(`observability-mcp running on stdio transport · connectors: ${registry
|
|
709
|
+
.getAll()
|
|
710
|
+
.map((c) => c.name)
|
|
711
|
+
.join(", ")}`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
435
714
|
// MCP Streamable HTTP transport — stateful sessions
|
|
436
715
|
const transports = new Map();
|
|
437
716
|
const sessionLastActive = new Map();
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface ConnectorManifest {
|
|
|
34
34
|
/** Semver range of mcp-server versions this connector supports. */
|
|
35
35
|
serverVersion?: string;
|
|
36
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* Subresource-integrity-style digest of the entry file
|
|
39
|
+
* ("sha256-<base64>"). Required (and signature-checked) when the
|
|
40
|
+
* server runs with VERIFY_PLUGINS=true. See docs/plugin-architecture.md.
|
|
41
|
+
*/
|
|
42
|
+
integrity?: string;
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
45
|
* The default export shape a connector plugin module must provide.
|
|
@@ -23,5 +23,6 @@ export declare const manifestSchema: z.ZodObject<{
|
|
|
23
23
|
compat: z.ZodOptional<z.ZodObject<{
|
|
24
24
|
serverVersion: z.ZodOptional<z.ZodString>;
|
|
25
25
|
}, z.core.$strip>>;
|
|
26
|
+
integrity: z.ZodOptional<z.ZodString>;
|
|
26
27
|
}, z.core.$strip>;
|
|
27
28
|
export type ValidatedConnectorManifest = z.infer<typeof manifestSchema>;
|
|
@@ -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
|
});
|