@thotischner/observability-mcp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/analysis/anomaly.d.ts +89 -0
  2. package/dist/analysis/anomaly.js +235 -0
  3. package/dist/analysis/anomaly.test.js +149 -1
  4. package/dist/analysis/backtest.d.ts +31 -0
  5. package/dist/analysis/backtest.js +206 -0
  6. package/dist/analysis/backtest.test.d.ts +1 -0
  7. package/dist/analysis/backtest.test.js +34 -0
  8. package/dist/analysis/correlator.d.ts +35 -0
  9. package/dist/analysis/correlator.js +95 -0
  10. package/dist/analysis/correlator.test.js +60 -1
  11. package/dist/analysis/health.d.ts +2 -3
  12. package/dist/analysis/index.d.ts +32 -0
  13. package/dist/analysis/index.js +29 -0
  14. package/dist/analysis/library.test.d.ts +1 -0
  15. package/dist/analysis/library.test.js +44 -0
  16. package/dist/auth/credentials.d.ts +29 -0
  17. package/dist/auth/credentials.js +76 -0
  18. package/dist/auth/credentials.test.d.ts +1 -0
  19. package/dist/auth/credentials.test.js +57 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +370 -0
  22. package/dist/cli/lib.d.ts +95 -0
  23. package/dist/cli/lib.js +185 -0
  24. package/dist/cli/lib.test.d.ts +1 -0
  25. package/dist/cli/lib.test.js +134 -0
  26. package/dist/connectors/hub.d.ts +48 -0
  27. package/dist/connectors/hub.js +51 -0
  28. package/dist/connectors/hub.test.d.ts +1 -0
  29. package/dist/connectors/hub.test.js +52 -0
  30. package/dist/connectors/install.d.ts +24 -0
  31. package/dist/connectors/install.js +100 -0
  32. package/dist/connectors/install.test.d.ts +1 -0
  33. package/dist/connectors/install.test.js +58 -0
  34. package/dist/connectors/loader.d.ts +5 -0
  35. package/dist/connectors/loader.js +54 -2
  36. package/dist/connectors/loki.js +11 -4
  37. package/dist/connectors/loki.test.js +27 -0
  38. package/dist/connectors/verify.d.ts +19 -0
  39. package/dist/connectors/verify.js +87 -0
  40. package/dist/connectors/verify.test.d.ts +1 -0
  41. package/dist/connectors/verify.test.js +63 -0
  42. package/dist/context.d.ts +27 -0
  43. package/dist/context.js +18 -0
  44. package/dist/index.js +322 -34
  45. package/dist/net/egress-policy.d.ts +31 -0
  46. package/dist/net/egress-policy.js +37 -0
  47. package/dist/net/egress-policy.test.d.ts +1 -0
  48. package/dist/net/egress-policy.test.js +52 -0
  49. package/dist/sdk/index.d.ts +6 -0
  50. package/dist/sdk/manifest-schema.d.ts +1 -0
  51. package/dist/sdk/manifest-schema.js +11 -0
  52. package/dist/tools/context-seam.test.d.ts +1 -0
  53. package/dist/tools/context-seam.test.js +23 -0
  54. package/dist/tools/detect-anomalies.d.ts +2 -1
  55. package/dist/tools/detect-anomalies.js +47 -11
  56. package/dist/tools/get-service-health.d.ts +2 -1
  57. package/dist/tools/get-service-health.js +2 -1
  58. package/dist/tools/handlers.test.js +73 -0
  59. package/dist/tools/list-services.d.ts +2 -1
  60. package/dist/tools/list-services.js +2 -1
  61. package/dist/tools/list-sources.d.ts +2 -1
  62. package/dist/tools/list-sources.js +2 -1
  63. package/dist/tools/query-logs.d.ts +2 -1
  64. package/dist/tools/query-logs.js +2 -1
  65. package/dist/tools/query-metrics.d.ts +2 -1
  66. package/dist/tools/query-metrics.js +9 -1
  67. package/dist/ui/index.html +119 -4
  68. package/package.json +18 -5
@@ -0,0 +1,185 @@
1
+ // Pure, IO-free helpers for the omcp CLI so they can be unit-tested
2
+ // without spawning docker or touching the filesystem.
3
+ /** Minimal argv parser: `omcp <command> [sub] [positionals] [--flag[=val]] [-f val]`. */
4
+ export function parseArgs(argv) {
5
+ const flags = {};
6
+ const rest = [];
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i];
9
+ if (a.startsWith("--")) {
10
+ const eq = a.indexOf("=");
11
+ if (eq !== -1)
12
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
13
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("-"))
14
+ flags[a.slice(2)] = argv[++i];
15
+ else
16
+ flags[a.slice(2)] = true;
17
+ }
18
+ else if (a.startsWith("-") && a.length > 1) {
19
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("-"))
20
+ flags[a.slice(1)] = argv[++i];
21
+ else
22
+ flags[a.slice(1)] = true;
23
+ }
24
+ else {
25
+ rest.push(a);
26
+ }
27
+ }
28
+ return { command: rest[0] ?? "", sub: rest[1], flags, positionals: rest.slice(2) };
29
+ }
30
+ /**
31
+ * Given a desired port and a predicate that says whether a port is in
32
+ * use, return the first free port at or after `desired` (bounded scan).
33
+ */
34
+ export function pickFreePort(desired, inUse, span = 50) {
35
+ for (let p = desired; p < desired + span; p++) {
36
+ if (!inUse(p))
37
+ return p;
38
+ }
39
+ throw new Error(`no free port in [${desired}, ${desired + span})`);
40
+ }
41
+ /**
42
+ * Build a docker-compose override that remaps the host side of the
43
+ * given service ports. Uses the `!override` tag so it replaces (not
44
+ * appends to) the base `ports:` list.
45
+ */
46
+ export function composeOverride(remaps) {
47
+ const services = remaps
48
+ .map((r) => ` ${r.service}:\n ports: !override\n - "${r.host}:${r.container}"`)
49
+ .join("\n");
50
+ return `services:\n${services}\n`;
51
+ }
52
+ export const DEFAULT_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
53
+ /**
54
+ * Decide where to read the catalog from, in priority order:
55
+ * 1. explicit `from` (a URL or a filesystem path)
56
+ * 2. a local checkout's hub/catalog/index.json (when localPath exists)
57
+ * 3. the public Pages catalog
58
+ */
59
+ export function resolveCatalogSource(from, localPath) {
60
+ if (from) {
61
+ return /^https?:\/\//.test(from)
62
+ ? { kind: "url", location: from }
63
+ : { kind: "file", location: from };
64
+ }
65
+ if (localPath)
66
+ return { kind: "file", location: localPath };
67
+ return { kind: "url", location: DEFAULT_CATALOG_URL };
68
+ }
69
+ export function formatPluginList(cat) {
70
+ const rows = cat.connectors
71
+ .slice()
72
+ .sort((a, b) => a.name.localeCompare(b.name))
73
+ .map((c) => {
74
+ const latest = c.latest ?? c.versions[0]?.version ?? "—";
75
+ const flags = [c.builtin ? "builtin" : "", c.tier].filter(Boolean).join(",");
76
+ return [c.name, latest, c.signalTypes.join("+"), flags];
77
+ });
78
+ const head = ["NAME", "LATEST", "SIGNALS", "TIER"];
79
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
80
+ const line = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
81
+ return [line(head), ...rows.map(line)].join("\n");
82
+ }
83
+ export function formatPluginInfo(c) {
84
+ const out = [];
85
+ out.push(`${c.displayName} (${c.name})`);
86
+ out.push(` tier: ${c.tier}${c.builtin ? " · builtin (ships in the server image)" : ""}`);
87
+ out.push(` signals: ${c.signalTypes.join(", ")}`);
88
+ out.push(` ${c.description}`);
89
+ out.push(` versions:`);
90
+ for (const v of c.versions) {
91
+ out.push(` - ${v.version}${v.releasedAt ? ` (${v.releasedAt})` : ""}${v.serverCompat ? ` · server ${v.serverCompat}` : ""}`);
92
+ if (v.integrity)
93
+ out.push(` integrity: ${v.integrity}`);
94
+ if (v.signatureUrl)
95
+ out.push(` signature: ${v.signatureUrl}`);
96
+ if (v.tarballUrl)
97
+ out.push(` tarball: ${v.tarballUrl}`);
98
+ if (v.changelog)
99
+ out.push(` changelog: ${v.changelog}`);
100
+ }
101
+ return out.join("\n");
102
+ }
103
+ /** Split "name" or "name@1.2.3" into parts. Throws on a malformed ref. */
104
+ export function parsePluginRef(ref) {
105
+ const m = ref.match(/^([a-z][a-z0-9-]*)(?:@(\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?))?$/);
106
+ if (!m)
107
+ throw new Error(`invalid plugin ref '${ref}' (expected name or name@x.y.z)`);
108
+ return { name: m[1], version: m[2] };
109
+ }
110
+ /**
111
+ * Resolve a catalog + ref into the concrete artifact to install.
112
+ * Returns {builtin:true} for image-bundled connectors (caller should
113
+ * no-op). Throws if the connector/version is unknown.
114
+ */
115
+ export function resolveInstall(cat, ref) {
116
+ const { name, version } = parsePluginRef(ref);
117
+ const c = cat.connectors.find((x) => x.name === name);
118
+ if (!c)
119
+ throw new Error(`no connector '${name}' in catalog (try: omcp plugin list)`);
120
+ if (c.builtin)
121
+ return { name, version: version ?? c.latest ?? "", builtin: true };
122
+ const v = version
123
+ ? c.versions.find((x) => x.version === version)
124
+ : c.versions.find((x) => x.version === (c.latest ?? c.versions[0]?.version)) ?? c.versions[0];
125
+ if (!v)
126
+ throw new Error(`version '${version}' not found for '${name}'`);
127
+ return {
128
+ name,
129
+ version: v.version,
130
+ builtin: false,
131
+ tarballUrl: v.tarballUrl,
132
+ signatureUrl: v.signatureUrl,
133
+ manifestUrl: v.manifestUrl,
134
+ integrity: v.integrity,
135
+ };
136
+ }
137
+ export const HELM_REPO_NAME = "observability-mcp";
138
+ export const HELM_REPO_URL = "https://thotischner.github.io/observability-mcp/";
139
+ export const HELM_CHART = "observability-mcp/observability-mcp";
140
+ /**
141
+ * Split argv at the first standalone "--": everything after it is
142
+ * passed verbatim to the wrapped tool (helm). Keeps omcp from having to
143
+ * re-implement helm's flag grammar (repeatable --set, -n, -f, ...).
144
+ */
145
+ export function splitPassthrough(argv) {
146
+ const i = argv.indexOf("--");
147
+ if (i === -1)
148
+ return { argv, passthrough: [] };
149
+ return { argv: argv.slice(0, i), passthrough: argv.slice(i + 1) };
150
+ }
151
+ /** The helm argv for the install/upgrade step (repo add/update are fixed). */
152
+ export function helmReleaseArgs(action, release, passthrough) {
153
+ const head = action === "upgrade"
154
+ ? ["upgrade", "--install", release, HELM_CHART]
155
+ : ["install", release, HELM_CHART];
156
+ return [...head, ...passthrough];
157
+ }
158
+ export const HELP = `omcp — observability-mcp control CLI
159
+
160
+ Usage:
161
+ omcp version Print CLI + server package version
162
+ omcp doctor Check the local toolchain (docker, compose, helm, node)
163
+ omcp demo up Start the full demo stack (auto-picks free host ports)
164
+ omcp demo down Stop and remove the demo stack
165
+ omcp demo status Show demo container status
166
+ omcp plugin list List connectors from the hub catalog
167
+ omcp plugin info <name> Show one connector's versions + verification info
168
+ omcp plugin install <ref> Install name[@version]: download, verify, extract
169
+ omcp plugin verify <dir> Verify an installed plugin dir against a trust root
170
+ omcp helm install [release] helm repo add+update, then install the signed chart
171
+ omcp helm upgrade [release] Same, as 'helm upgrade --install'
172
+ omcp help Show this help
173
+
174
+ Pass extra helm flags after a literal --, e.g.:
175
+ omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090
176
+
177
+ Flags:
178
+ --json Machine-readable output (doctor, status, plugin)
179
+ --from <url|path> Catalog source (default: local checkout or the public hub)
180
+ --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>
181
+ --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)
182
+ --insecure Skip verification (NOT recommended; explicit opt-out)
183
+ --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)
184
+ --force Overwrite an existing install dir
185
+ `;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,134 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseArgs, pickFreePort, composeOverride, resolveCatalogSource, formatPluginList, formatPluginInfo, DEFAULT_CATALOG_URL, } from "./lib.js";
4
+ const CAT = {
5
+ catalogVersion: 1,
6
+ connectors: [
7
+ {
8
+ name: "prometheus",
9
+ displayName: "Prometheus",
10
+ description: "PromQL metrics.",
11
+ tier: "official",
12
+ builtin: true,
13
+ signalTypes: ["metrics"],
14
+ latest: "1.4.0",
15
+ versions: [{ version: "1.4.0", releasedAt: "2026-05-15", serverCompat: ">=1.4.0" }],
16
+ },
17
+ {
18
+ name: "tempo",
19
+ displayName: "Grafana Tempo",
20
+ description: "TraceQL.",
21
+ tier: "third-party",
22
+ signalTypes: ["traces"],
23
+ versions: [
24
+ { version: "1.0.0", releasedAt: "2026-05-15", integrity: "sha256-AAAA=", signatureUrl: "https://x/s.sig" },
25
+ ],
26
+ },
27
+ ],
28
+ };
29
+ test("parseArgs: command, sub, positionals", () => {
30
+ const p = parseArgs(["demo", "up", "extra"]);
31
+ assert.equal(p.command, "demo");
32
+ assert.equal(p.sub, "up");
33
+ assert.deepEqual(p.positionals, ["extra"]);
34
+ });
35
+ test("parseArgs: --flag=val, --flag val, boolean, -f val", () => {
36
+ const p = parseArgs(["plugin", "install", "loki", "--from=/m", "--ver", "1.2.0", "--json", "-f", "x"]);
37
+ assert.equal(p.command, "plugin");
38
+ assert.equal(p.sub, "install");
39
+ assert.deepEqual(p.positionals, ["loki"]);
40
+ assert.equal(p.flags.from, "/m");
41
+ assert.equal(p.flags.ver, "1.2.0");
42
+ assert.equal(p.flags.json, true);
43
+ assert.equal(p.flags.f, "x");
44
+ });
45
+ test("parseArgs: empty argv", () => {
46
+ const p = parseArgs([]);
47
+ assert.equal(p.command, "");
48
+ assert.equal(p.sub, undefined);
49
+ });
50
+ test("pickFreePort returns desired when free", () => {
51
+ assert.equal(pickFreePort(3000, () => false), 3000);
52
+ });
53
+ test("pickFreePort skips used ports", () => {
54
+ const used = new Set([3000, 3001, 3002]);
55
+ assert.equal(pickFreePort(3000, (p) => used.has(p)), 3003);
56
+ });
57
+ test("pickFreePort throws when span exhausted", () => {
58
+ assert.throws(() => pickFreePort(3000, () => true, 5), /no free port/);
59
+ });
60
+ test("composeOverride emits !override port mappings", () => {
61
+ const y = composeOverride([
62
+ { service: "mcp-server", host: 3001, container: 3000 },
63
+ { service: "loki", host: 3101, container: 3100 },
64
+ ]);
65
+ assert.match(y, /^services:\n/);
66
+ assert.match(y, / mcp-server:\n ports: !override\n - "3001:3000"/);
67
+ assert.match(y, / loki:\n ports: !override\n - "3101:3100"/);
68
+ });
69
+ test("resolveCatalogSource: explicit url, explicit path, local, default", () => {
70
+ assert.deepEqual(resolveCatalogSource("https://h/x.json", null), { kind: "url", location: "https://h/x.json" });
71
+ assert.deepEqual(resolveCatalogSource("/tmp/c.json", null), { kind: "file", location: "/tmp/c.json" });
72
+ assert.deepEqual(resolveCatalogSource(undefined, "/repo/hub/catalog/index.json"), { kind: "file", location: "/repo/hub/catalog/index.json" });
73
+ assert.deepEqual(resolveCatalogSource(undefined, null), { kind: "url", location: DEFAULT_CATALOG_URL });
74
+ });
75
+ test("formatPluginList: header, sorted rows, builtin+tier flags", () => {
76
+ const out = formatPluginList(CAT);
77
+ const lines = out.split("\n");
78
+ assert.match(lines[0], /^NAME\s+LATEST\s+SIGNALS\s+TIER$/);
79
+ // sorted: prometheus before tempo
80
+ assert.ok(lines[1].startsWith("prometheus"));
81
+ assert.match(lines[1], /builtin,official/);
82
+ assert.ok(lines[2].startsWith("tempo"));
83
+ assert.match(lines[2], /third-party/);
84
+ });
85
+ test("formatPluginInfo: versions + integrity/signature surfaced", () => {
86
+ const info = formatPluginInfo(CAT.connectors[1]);
87
+ assert.match(info, /Grafana Tempo {2}\(tempo\)/);
88
+ assert.match(info, /tier: {6}third-party/);
89
+ assert.match(info, /1\.0\.0 \(2026-05-15\)/);
90
+ assert.match(info, /integrity: sha256-AAAA=/);
91
+ assert.match(info, /signature: https:\/\/x\/s\.sig/);
92
+ });
93
+ import { parsePluginRef, resolveInstall } from "./lib.js";
94
+ test("parsePluginRef: name and name@version, rejects junk", () => {
95
+ assert.deepEqual(parsePluginRef("tempo"), { name: "tempo", version: undefined });
96
+ assert.deepEqual(parsePluginRef("tempo@1.2.3"), { name: "tempo", version: "1.2.3" });
97
+ assert.deepEqual(parsePluginRef("x@1.0.0-rc.1"), { name: "x", version: "1.0.0-rc.1" });
98
+ assert.throws(() => parsePluginRef("Bad Name"), /invalid plugin ref/);
99
+ assert.throws(() => parsePluginRef("tempo@v1"), /invalid plugin ref/);
100
+ });
101
+ test("resolveInstall: builtin short-circuits", () => {
102
+ const r = resolveInstall(CAT, "prometheus");
103
+ assert.equal(r.builtin, true);
104
+ assert.equal(r.name, "prometheus");
105
+ });
106
+ test("resolveInstall: picks latest then specific version", () => {
107
+ const def = resolveInstall(CAT, "tempo");
108
+ assert.equal(def.version, "1.0.0");
109
+ assert.equal(def.builtin, false);
110
+ assert.equal(def.integrity, "sha256-AAAA=");
111
+ const pinned = resolveInstall(CAT, "tempo@1.0.0");
112
+ assert.equal(pinned.version, "1.0.0");
113
+ assert.throws(() => resolveInstall(CAT, "tempo@9.9.9"), /not found/);
114
+ assert.throws(() => resolveInstall(CAT, "ghost"), /no connector/);
115
+ });
116
+ import { splitPassthrough, helmReleaseArgs, HELM_CHART } from "./lib.js";
117
+ test("splitPassthrough: splits at first -- ", () => {
118
+ assert.deepEqual(splitPassthrough(["helm", "install", "obs"]), {
119
+ argv: ["helm", "install", "obs"],
120
+ passthrough: [],
121
+ });
122
+ assert.deepEqual(splitPassthrough(["helm", "upgrade", "obs", "--", "-n", "mon", "--set", "a=b"]), { argv: ["helm", "upgrade", "obs"], passthrough: ["-n", "mon", "--set", "a=b"] });
123
+ });
124
+ test("helmReleaseArgs: install vs upgrade --install, passthrough appended", () => {
125
+ assert.deepEqual(helmReleaseArgs("install", "obs", []), ["install", "obs", HELM_CHART]);
126
+ assert.deepEqual(helmReleaseArgs("upgrade", "obs", ["-n", "mon"]), [
127
+ "upgrade",
128
+ "--install",
129
+ "obs",
130
+ HELM_CHART,
131
+ "-n",
132
+ "mon",
133
+ ]);
134
+ });
@@ -0,0 +1,48 @@
1
+ import type { LoadedConnector } from "./loader.js";
2
+ export declare const DEFAULT_HUB_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
3
+ /** Catalog source: env override wins (airgapped mirror / private hub). */
4
+ export declare function resolveHubCatalogUrl(env?: NodeJS.ProcessEnv): string;
5
+ export interface InstalledConnector {
6
+ name: string;
7
+ source: "builtin" | "filesystem" | "config";
8
+ displayName: string;
9
+ description: string;
10
+ version: string | null;
11
+ signalTypes: string[];
12
+ capabilities: Record<string, boolean>;
13
+ }
14
+ /** Shape the loader's entries into the UI's installed list. */
15
+ export declare function describeInstalled(loaded: LoadedConnector[]): InstalledConnector[];
16
+ export interface CatalogConnector {
17
+ name: string;
18
+ displayName: string;
19
+ description: string;
20
+ tier: string;
21
+ builtin?: boolean;
22
+ signalTypes: string[];
23
+ latest?: string;
24
+ versions: Array<{
25
+ version: string;
26
+ tarballUrl?: string;
27
+ integrity?: string;
28
+ serverCompat?: string;
29
+ changelog?: string;
30
+ releasedAt?: string;
31
+ }>;
32
+ }
33
+ export interface HubCatalogEntry extends CatalogConnector {
34
+ installed: boolean;
35
+ installedVersion: string | null;
36
+ }
37
+ /**
38
+ * Merge the hub catalog with what's installed so the UI can show
39
+ * status + offer not-yet-installed connectors.
40
+ */
41
+ export declare function mergeCatalog(catalog: {
42
+ connectors?: CatalogConnector[];
43
+ } | null, installed: InstalledConnector[]): HubCatalogEntry[];
44
+ /** Fetch + parse the catalog. fetchImpl injected for tests. */
45
+ export declare function fetchHubCatalog(url: string, fetchImpl?: typeof fetch): Promise<{
46
+ connectors: CatalogConnector[];
47
+ catalogVersion?: number;
48
+ }>;
@@ -0,0 +1,51 @@
1
+ // Connector-hub integration helpers (pure, IO-injectable so they unit
2
+ // test without network). Powers the Web UI "Connectors" page:
3
+ // - what's installed/loaded right now
4
+ // - what the hub catalog offers, with an "installed" marker
5
+ export const DEFAULT_HUB_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
6
+ /** Catalog source: env override wins (airgapped mirror / private hub). */
7
+ export function resolveHubCatalogUrl(env = process.env) {
8
+ return env.HUB_CATALOG_URL || DEFAULT_HUB_CATALOG_URL;
9
+ }
10
+ /** Shape the loader's entries into the UI's installed list. */
11
+ export function describeInstalled(loaded) {
12
+ return loaded
13
+ .map((p) => ({
14
+ name: p.name,
15
+ source: p.source,
16
+ displayName: p.manifest?.displayName ?? p.name,
17
+ description: p.manifest?.description ?? "",
18
+ version: p.manifest?.version ?? null,
19
+ signalTypes: p.manifest?.signalTypes ?? [],
20
+ capabilities: p.manifest?.capabilities ?? {},
21
+ }))
22
+ .sort((a, b) => a.name.localeCompare(b.name));
23
+ }
24
+ /**
25
+ * Merge the hub catalog with what's installed so the UI can show
26
+ * status + offer not-yet-installed connectors.
27
+ */
28
+ export function mergeCatalog(catalog, installed) {
29
+ const byName = new Map(installed.map((i) => [i.name, i]));
30
+ return (catalog?.connectors ?? [])
31
+ .map((c) => {
32
+ const have = byName.get(c.name);
33
+ return {
34
+ ...c,
35
+ installed: !!have,
36
+ installedVersion: have?.version ?? null,
37
+ };
38
+ })
39
+ .sort((a, b) => a.name.localeCompare(b.name));
40
+ }
41
+ /** Fetch + parse the catalog. fetchImpl injected for tests. */
42
+ export async function fetchHubCatalog(url, fetchImpl = fetch) {
43
+ const res = await fetchImpl(url);
44
+ if (!res.ok)
45
+ throw new Error(`hub catalog HTTP ${res.status} from ${url}`);
46
+ const body = (await res.json());
47
+ if (!body || !Array.isArray(body.connectors)) {
48
+ throw new Error("hub catalog malformed (no connectors[])");
49
+ }
50
+ return { connectors: body.connectors, catalogVersion: body.catalogVersion };
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, DEFAULT_HUB_CATALOG_URL, } from "./hub.js";
4
+ test("resolveHubCatalogUrl: default + env override", () => {
5
+ assert.equal(resolveHubCatalogUrl({}), DEFAULT_HUB_CATALOG_URL);
6
+ assert.equal(resolveHubCatalogUrl({ HUB_CATALOG_URL: "http://mirror/idx.json" }), "http://mirror/idx.json");
7
+ });
8
+ test("describeInstalled maps loader entries, sorts, defaults", () => {
9
+ const loaded = [
10
+ { name: "loki", source: "builtin", factory: () => ({}) },
11
+ {
12
+ name: "datadog",
13
+ source: "filesystem",
14
+ factory: () => ({}),
15
+ manifest: {
16
+ displayName: "Datadog",
17
+ description: "DD",
18
+ version: "1.0.0",
19
+ signalTypes: ["metrics", "logs"],
20
+ capabilities: { queryMetrics: true },
21
+ },
22
+ },
23
+ ];
24
+ const d = describeInstalled(loaded);
25
+ assert.deepEqual(d.map((x) => x.name), ["datadog", "loki"]); // sorted
26
+ assert.equal(d[0].displayName, "Datadog");
27
+ assert.deepEqual(d[0].signalTypes, ["metrics", "logs"]);
28
+ assert.equal(d[1].displayName, "loki"); // falls back to name
29
+ assert.equal(d[1].version, null);
30
+ assert.deepEqual(d[1].capabilities, {});
31
+ });
32
+ test("mergeCatalog marks installed + version, sorts, tolerates null", () => {
33
+ const installed = describeInstalled([
34
+ { name: "datadog", source: "filesystem", factory: () => ({}), manifest: { version: "1.0.0" } },
35
+ ]);
36
+ const merged = mergeCatalog({ connectors: [
37
+ { name: "grafana", displayName: "Grafana", description: "", tier: "official", signalTypes: ["metrics"], versions: [{ version: "1.0.0" }] },
38
+ { name: "datadog", displayName: "Datadog", description: "", tier: "official", signalTypes: ["metrics"], versions: [{ version: "1.0.0" }] },
39
+ ] }, installed);
40
+ assert.deepEqual(merged.map((m) => m.name), ["datadog", "grafana"]);
41
+ assert.equal(merged[0].installed, true);
42
+ assert.equal(merged[0].installedVersion, "1.0.0");
43
+ assert.equal(merged[1].installed, false);
44
+ assert.deepEqual(mergeCatalog(null, installed), []);
45
+ });
46
+ test("fetchHubCatalog: ok, http error, malformed", async () => {
47
+ const ok = async () => ({ ok: true, status: 200, json: async () => ({ connectors: [{ name: "x" }], catalogVersion: 1 }) });
48
+ const r = await fetchHubCatalog("u", ok);
49
+ assert.equal(r.connectors.length, 1);
50
+ await assert.rejects(() => fetchHubCatalog("u", (async () => ({ ok: false, status: 503, json: async () => ({}) }))), /HTTP 503/);
51
+ await assert.rejects(() => fetchHubCatalog("u", (async () => ({ ok: true, status: 200, json: async () => ({}) }))), /malformed/);
52
+ });
@@ -0,0 +1,24 @@
1
+ /** A connector name is safe iff kebab-case ASCII (also blocks traversal). */
2
+ export declare function isValidConnectorName(name: unknown): name is string;
3
+ /**
4
+ * Resolve the install target and guarantee it stays directly inside
5
+ * pluginsDir (defence-in-depth against `..`/absolute names even though
6
+ * isValidConnectorName already rejects them).
7
+ */
8
+ export declare function safeTarget(pluginsDir: string, name: string): string;
9
+ export interface InstallResult {
10
+ name: string;
11
+ version: string | null;
12
+ }
13
+ /**
14
+ * Install a connector tarball into pluginsDir, fail-closed. Always
15
+ * verifies the manifest signature + entry integrity against trustRoot
16
+ * — there is intentionally NO insecure bypass on this path (it's
17
+ * reachable over HTTP). Throws PluginVerificationError on any failure.
18
+ */
19
+ export declare function installTarball(opts: {
20
+ tgzPath: string;
21
+ pluginsDir: string;
22
+ trustRootPath: string;
23
+ expectedName?: string;
24
+ }): InstallResult;
@@ -0,0 +1,100 @@
1
+ // Shared connector-install core: extract a tarball, verify it
2
+ // fail-closed against a trust root (the SAME crypto as the server
3
+ // loader / omcp CLI — verify.ts), then atomically place it under
4
+ // PLUGINS_DIR. Used by the Web UI install API (and reusable by the
5
+ // CLI). Pure guards are split out for unit testing.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, rmSync, cpSync, } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
11
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
12
+ /** A connector name is safe iff kebab-case ASCII (also blocks traversal). */
13
+ export function isValidConnectorName(name) {
14
+ return typeof name === "string" && NAME_RE.test(name);
15
+ }
16
+ /**
17
+ * Resolve the install target and guarantee it stays directly inside
18
+ * pluginsDir (defence-in-depth against `..`/absolute names even though
19
+ * isValidConnectorName already rejects them).
20
+ */
21
+ export function safeTarget(pluginsDir, name) {
22
+ if (!isValidConnectorName(name))
23
+ throw new Error(`invalid connector name: ${String(name)}`);
24
+ const base = resolve(pluginsDir);
25
+ const target = resolve(base, name);
26
+ if (target !== join(base, name) || !target.startsWith(base + "/")) {
27
+ throw new Error("refusing path outside PLUGINS_DIR");
28
+ }
29
+ return target;
30
+ }
31
+ function tarExtract(tgz, dest) {
32
+ const r = spawnSync("tar", ["-xzf", tgz, "-C", dest], { stdio: "pipe" });
33
+ if (r.status !== 0)
34
+ throw new Error(`tar extraction failed: ${r.stderr?.toString() || r.status}`);
35
+ }
36
+ function findPluginRoot(base) {
37
+ for (const dir of [base, ...readdirSync(base).map((e) => join(base, e))]) {
38
+ try {
39
+ if (!statSync(dir).isDirectory())
40
+ continue;
41
+ const pkgPath = join(dir, "package.json");
42
+ if (!existsSync(pkgPath))
43
+ continue;
44
+ if (JSON.parse(readFileSync(pkgPath, "utf8")).observabilityMcp?.kind === "connector")
45
+ return dir;
46
+ }
47
+ catch {
48
+ /* skip */
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Install a connector tarball into pluginsDir, fail-closed. Always
55
+ * verifies the manifest signature + entry integrity against trustRoot
56
+ * — there is intentionally NO insecure bypass on this path (it's
57
+ * reachable over HTTP). Throws PluginVerificationError on any failure.
58
+ */
59
+ export function installTarball(opts) {
60
+ const trustRoot = loadTrustRoot(opts.trustRootPath); // throws if unreadable/bad
61
+ const work = mkdtempSync(join(tmpdir(), "obsmcp-install-"));
62
+ try {
63
+ const stage = join(work, "stage");
64
+ mkdirSync(stage);
65
+ tarExtract(opts.tgzPath, stage);
66
+ const root = findPluginRoot(stage);
67
+ if (!root)
68
+ throw new PluginVerificationError("tarball has no connector package.json marker");
69
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
70
+ const marker = pkg.observabilityMcp;
71
+ const name = marker?.name;
72
+ if (!isValidConnectorName(name))
73
+ throw new PluginVerificationError(`invalid connector name in package: ${String(name)}`);
74
+ if (opts.expectedName && opts.expectedName !== name) {
75
+ throw new PluginVerificationError(`tarball is '${name}', expected '${opts.expectedName}'`);
76
+ }
77
+ const manifestRel = marker.manifest || "./manifest.json";
78
+ const manifestPath = resolve(root, manifestRel);
79
+ if (!existsSync(manifestPath))
80
+ throw new PluginVerificationError(`manifest not found: ${manifestRel}`);
81
+ const sigPath = manifestPath + ".sig";
82
+ if (!existsSync(sigPath))
83
+ throw new PluginVerificationError(`missing manifest signature: ${manifestRel}.sig`);
84
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
85
+ const entryPath = resolve(root, pkg.main || "index.js");
86
+ // Fail-closed: signature over the manifest + manifest pins the
87
+ // entry hash. Throws PluginVerificationError on mismatch.
88
+ verifyManifestSignature(readFileSync(manifestPath), readFileSync(sigPath), trustRoot);
89
+ verifyIntegrity(entryPath, manifest.integrity);
90
+ const target = safeTarget(opts.pluginsDir, name);
91
+ mkdirSync(opts.pluginsDir, { recursive: true });
92
+ if (existsSync(target))
93
+ rmSync(target, { recursive: true, force: true });
94
+ cpSync(root, target, { recursive: true });
95
+ return { name, version: manifest.version ?? null };
96
+ }
97
+ finally {
98
+ rmSync(work, { recursive: true, force: true });
99
+ }
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generateKeyPairSync, sign as edSign, createHash } from "node:crypto";
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { isValidConnectorName, safeTarget, installTarball } from "./install.js";
10
+ import { PluginVerificationError } from "./verify.js";
11
+ test("isValidConnectorName: kebab only, blocks traversal", () => {
12
+ assert.ok(isValidConnectorName("datadog"));
13
+ assert.ok(isValidConnectorName("my-connector9"));
14
+ for (const bad of ["../evil", "Foo", "a/b", "", "1abc", "a_b", null, 42]) {
15
+ assert.equal(isValidConnectorName(bad), false);
16
+ }
17
+ });
18
+ test("safeTarget keeps the path inside pluginsDir", () => {
19
+ const t = safeTarget("/plugins", "datadog");
20
+ assert.equal(t, "/plugins/datadog");
21
+ assert.throws(() => safeTarget("/plugins", "../etc"), /invalid connector name|outside/);
22
+ });
23
+ function mkSignedTarball(dir, name, priv, opts = {}) {
24
+ const src = join(dir, "src");
25
+ mkdirSync(src, { recursive: true });
26
+ writeFileSync(join(src, "index.js"), "export default () => ({});\n");
27
+ const integ = "sha256-" + createHash("sha256").update(readFileSync(join(src, "index.js"))).digest("base64");
28
+ const manifest = { schemaVersion: 1, name, displayName: name, version: "1.0.0", description: "x", signalTypes: ["metrics"], integrity: integ };
29
+ const mb = Buffer.from(JSON.stringify(manifest));
30
+ writeFileSync(join(src, "manifest.json"), mb);
31
+ if (!opts.noSig)
32
+ writeFileSync(join(src, "manifest.json.sig"), edSign(null, mb, priv));
33
+ writeFileSync(join(src, "package.json"), JSON.stringify({ name, main: "index.js", observabilityMcp: { kind: "connector", name, manifest: "./manifest.json" } }));
34
+ if (opts.tamper)
35
+ writeFileSync(join(src, "index.js"), "export default () => ({}); /* tampered */\n");
36
+ const tgz = join(dir, `${name}-1.0.0.tgz`);
37
+ const r = spawnSync("tar", ["-czf", tgz, "-C", src, "."]);
38
+ assert.equal(r.status, 0, "tar failed in test setup");
39
+ return tgz;
40
+ }
41
+ test("installTarball: verifies + installs; rejects tamper / missing sig / wrong name", () => {
42
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
43
+ const work = mkdtempSync(join(tmpdir(), "it-"));
44
+ const pub = join(work, "pub.pem");
45
+ writeFileSync(pub, publicKey.export({ type: "spki", format: "pem" }));
46
+ const pluginsDir = join(work, "plugins");
47
+ const good = mkSignedTarball(join(work, "good"), "datadog", privateKey);
48
+ const r = installTarball({ tgzPath: good, pluginsDir, trustRootPath: pub });
49
+ assert.equal(r.name, "datadog");
50
+ assert.equal(r.version, "1.0.0");
51
+ assert.ok(existsSync(join(pluginsDir, "datadog", "manifest.json")));
52
+ const tampered = mkSignedTarball(join(work, "bad"), "datadog", privateKey, { tamper: true });
53
+ assert.throws(() => installTarball({ tgzPath: tampered, pluginsDir, trustRootPath: pub }), PluginVerificationError);
54
+ const noSig = mkSignedTarball(join(work, "nosig"), "datadog", privateKey, { noSig: true });
55
+ assert.throws(() => installTarball({ tgzPath: noSig, pluginsDir, trustRootPath: pub }), /missing manifest signature/);
56
+ const other = mkSignedTarball(join(work, "other"), "grafana", privateKey);
57
+ assert.throws(() => installTarball({ tgzPath: other, pluginsDir, trustRootPath: pub, expectedName: "datadog" }), /expected 'datadog'/);
58
+ });