@thotischner/observability-mcp 1.3.4 → 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/config/loader.test.js +3 -3
- 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 +48 -0
- package/dist/connectors/loader.js +222 -0
- package/dist/connectors/loki.js +14 -6
- package/dist/connectors/loki.test.js +27 -0
- package/dist/connectors/registry.d.ts +3 -0
- package/dist/connectors/registry.js +16 -16
- package/dist/connectors/tls.test.js +3 -3
- 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 +389 -26
- package/dist/metrics/instrument-connector.d.ts +8 -0
- package/dist/metrics/instrument-connector.js +41 -0
- package/dist/metrics/self.d.ts +12 -0
- package/dist/metrics/self.js +61 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +186 -0
- package/dist/sdk/index.d.ts +52 -0
- package/dist/sdk/index.js +13 -0
- package/dist/sdk/manifest-schema.d.ts +28 -0
- package/dist/sdk/manifest-schema.js +47 -0
- package/dist/sdk/manifest-schema.test.d.ts +1 -0
- package/dist/sdk/manifest-schema.test.js +50 -0
- package/dist/tools/get-service-health.js +3 -2
- package/dist/ui/index.html +687 -115
- package/dist/util/sanitize.d.ts +1 -0
- package/dist/util/sanitize.js +6 -0
- package/package.json +21 -8
|
@@ -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
|
+
});
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { writeFileSync,
|
|
3
|
+
import { writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { substituteEnv } from "./loader.js";
|
|
7
7
|
// We test the helper functions by importing the module fresh with different env vars.
|
|
8
8
|
// Since the config path is resolved at import time, we use dynamic imports.
|
|
9
|
-
|
|
9
|
+
let TMP_DIR;
|
|
10
10
|
describe("config/loader", () => {
|
|
11
11
|
beforeEach(() => {
|
|
12
|
-
|
|
12
|
+
TMP_DIR = mkdtempSync(join(tmpdir(), "observability-mcp-test-"));
|
|
13
13
|
});
|
|
14
14
|
afterEach(() => {
|
|
15
15
|
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ObservabilityConnector } from "./interface.js";
|
|
2
|
+
import type { ConnectorFactory, ConnectorManifest } from "../sdk/index.js";
|
|
3
|
+
export interface LoadedConnector {
|
|
4
|
+
/** Connector type id, e.g. "prometheus". Matches `source.type` in sources.yaml. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Where this connector came from (debug + UI display). */
|
|
7
|
+
source: "builtin" | "filesystem" | "config";
|
|
8
|
+
/** Optional metadata for plugins that ship a manifest.json. */
|
|
9
|
+
manifest?: ConnectorManifest;
|
|
10
|
+
factory: ConnectorFactory;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolves which connector implementations the server should know about,
|
|
14
|
+
* applying three sources in order (later overrides earlier):
|
|
15
|
+
* 1. builtin shim — Prometheus/Loki bundled with the server
|
|
16
|
+
* 2. filesystem — every subdir of PLUGINS_DIR with a valid package.json
|
|
17
|
+
* 3. config-pinned — `plugins:` block in sources.yaml (not yet wired)
|
|
18
|
+
*
|
|
19
|
+
* The legacy `connectorFactories` map in registry.ts can be replaced
|
|
20
|
+
* with this loader's output without changing observable behaviour.
|
|
21
|
+
*/
|
|
22
|
+
export declare class PluginLoader {
|
|
23
|
+
private connectors;
|
|
24
|
+
private pluginsDir;
|
|
25
|
+
private disabled;
|
|
26
|
+
private verify;
|
|
27
|
+
private trustRootPath?;
|
|
28
|
+
private trustRoot?;
|
|
29
|
+
constructor(opts?: {
|
|
30
|
+
pluginsDir?: string;
|
|
31
|
+
disabled?: string[];
|
|
32
|
+
verify?: boolean;
|
|
33
|
+
trustRoot?: string;
|
|
34
|
+
});
|
|
35
|
+
load(): Promise<void>;
|
|
36
|
+
list(): LoadedConnector[];
|
|
37
|
+
get(name: string): LoadedConnector | undefined;
|
|
38
|
+
has(name: string): boolean;
|
|
39
|
+
supportedTypes(): string[];
|
|
40
|
+
/** Create a fresh instance of a connector. Returns undefined for unknown types. */
|
|
41
|
+
create(name: string): ObservabilityConnector | undefined;
|
|
42
|
+
private loadBuiltins;
|
|
43
|
+
private loadFilesystem;
|
|
44
|
+
private loadFilesystemPlugin;
|
|
45
|
+
private register;
|
|
46
|
+
}
|
|
47
|
+
export declare function getPluginLoader(): PluginLoader;
|
|
48
|
+
export declare function setPluginLoader(loader: PluginLoader): void;
|