@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.
@@ -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", "List all configured observability backends and their connection status. Use this to discover what data sources are available.", {}, async () => withToolMetrics("list_sources", () => listSourcesHandler(registry)));
87
- mcpServer.tool("list_services", "List all monitored services discovered across all connected backends. Returns service names, their data sources, and signal types (metrics/logs).", { filter: z.string().optional().describe("Optional filter to match service names") }, async (args) => withToolMetrics("list_services", () => listServicesHandler(registry, args)));
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", `Query a specific metric for a service over a given timeframe. Returns time-series data with pre-computed summary statistics (current, average, min, max, trend). Available metrics: ${metricsList}`, {
92
- service: z.string().describe("Service name (e.g. 'api-gateway', 'payment-service')"),
93
- metric: z.string().describe(`Metric name. Available: ${uniqueNames.join(", ")}`),
94
- duration: z.string().optional().describe("Time range (e.g. '5m', '1h', '24h'). Default: '5m'"),
95
- source: z.string().optional().describe("Specific source name. If omitted, queries all metrics backends."),
96
- groupBy: z.string().optional().describe("Label to break the result down by, e.g. 'instance', 'pod', 'node'. Returns one series per distinct value in 'groups'."),
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", "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns.", {
99
- service: z.string().describe("Service name (e.g. 'payment-service')"),
100
- query: z.string().optional().describe("Optional search query to filter log messages (regex supported)"),
101
- duration: z.string().optional().describe("Time range (e.g. '5m', '1h', '24h'). Default: '5m'"),
102
- level: z.string().optional().describe("Filter by log level: 'error', 'warn', 'info', 'debug'"),
103
- limit: z.number().optional().describe("Maximum log entries to return. Default: 100"),
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", "Get an aggregated health overview for a service combining metrics AND logs. Returns health score (0-100), status (healthy/degraded/critical), key metrics, log error summary, anomalies, and cross-signal correlations.", {
106
- service: z.string().describe("Service name to check health for"),
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", "Scan for anomalies across all monitored services (or a specific one). Uses z-score analysis on metrics, checks log error spikes, and correlates signals. Returns anomalies with severity ratings.", {
109
- service: z.string().optional().describe("Specific service to scan. If omitted, scans all."),
110
- duration: z.string().optional().describe("Time range to analyze (e.g. '5m', '15m', '1h'). Default: '10m'"),
111
- sensitivity: z.enum(["low", "medium", "high"]).optional().describe("Detection sensitivity: low (>3σ), medium (>2σ), high (>1.5σ). Default: 'medium'"),
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((_req, res, next) => {
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();
@@ -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
  });