@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.
Files changed (44) hide show
  1. package/dist/cli/index.d.ts +2 -0
  2. package/dist/cli/index.js +370 -0
  3. package/dist/cli/lib.d.ts +95 -0
  4. package/dist/cli/lib.js +185 -0
  5. package/dist/cli/lib.test.d.ts +1 -0
  6. package/dist/cli/lib.test.js +134 -0
  7. package/dist/config/loader.test.js +3 -3
  8. package/dist/connectors/hub.d.ts +48 -0
  9. package/dist/connectors/hub.js +51 -0
  10. package/dist/connectors/hub.test.d.ts +1 -0
  11. package/dist/connectors/hub.test.js +52 -0
  12. package/dist/connectors/install.d.ts +24 -0
  13. package/dist/connectors/install.js +100 -0
  14. package/dist/connectors/install.test.d.ts +1 -0
  15. package/dist/connectors/install.test.js +58 -0
  16. package/dist/connectors/loader.d.ts +48 -0
  17. package/dist/connectors/loader.js +222 -0
  18. package/dist/connectors/loki.js +14 -6
  19. package/dist/connectors/loki.test.js +27 -0
  20. package/dist/connectors/registry.d.ts +3 -0
  21. package/dist/connectors/registry.js +16 -16
  22. package/dist/connectors/tls.test.js +3 -3
  23. package/dist/connectors/verify.d.ts +19 -0
  24. package/dist/connectors/verify.js +87 -0
  25. package/dist/connectors/verify.test.d.ts +1 -0
  26. package/dist/connectors/verify.test.js +63 -0
  27. package/dist/index.js +389 -26
  28. package/dist/metrics/instrument-connector.d.ts +8 -0
  29. package/dist/metrics/instrument-connector.js +41 -0
  30. package/dist/metrics/self.d.ts +12 -0
  31. package/dist/metrics/self.js +61 -0
  32. package/dist/openapi.d.ts +2 -0
  33. package/dist/openapi.js +186 -0
  34. package/dist/sdk/index.d.ts +52 -0
  35. package/dist/sdk/index.js +13 -0
  36. package/dist/sdk/manifest-schema.d.ts +28 -0
  37. package/dist/sdk/manifest-schema.js +47 -0
  38. package/dist/sdk/manifest-schema.test.d.ts +1 -0
  39. package/dist/sdk/manifest-schema.test.js +50 -0
  40. package/dist/tools/get-service-health.js +3 -2
  41. package/dist/ui/index.html +687 -115
  42. package/dist/util/sanitize.d.ts +1 -0
  43. package/dist/util/sanitize.js +6 -0
  44. package/package.json +21 -8
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { createConnection } from "node:net";
4
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, cpSync, } from "node:fs";
5
+ import { join, dirname, resolve } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseArgs, pickFreePort, composeOverride, resolveCatalogSource, formatPluginList, formatPluginInfo, resolveInstall, splitPassthrough, helmReleaseArgs, HELM_REPO_NAME, HELM_REPO_URL, HELP, } from "./lib.js";
9
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "../connectors/verify.js";
10
+ function pkgVersion() {
11
+ try {
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ // dist/cli/index.js → ../../package.json
14
+ return JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8")).version;
15
+ }
16
+ catch {
17
+ return "unknown";
18
+ }
19
+ }
20
+ function which(bin, args = ["--version"]) {
21
+ const r = spawnSync(bin, args, { encoding: "utf8" });
22
+ if (r.status === 0)
23
+ return (r.stdout || r.stderr || "").trim().split("\n")[0];
24
+ return null;
25
+ }
26
+ function dockerComposeVersion() {
27
+ const r = spawnSync("docker", ["compose", "version"], { encoding: "utf8" });
28
+ return r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : null;
29
+ }
30
+ // Walk up from cwd looking for the repo's docker-compose.yml.
31
+ function findComposeFile() {
32
+ let dir = process.cwd();
33
+ for (let i = 0; i < 8; i++) {
34
+ const f = join(dir, "docker-compose.yml");
35
+ if (existsSync(f))
36
+ return f;
37
+ const parent = dirname(dir);
38
+ if (parent === dir)
39
+ break;
40
+ dir = parent;
41
+ }
42
+ return null;
43
+ }
44
+ // Walk up from cwd for a checkout's generated catalog.
45
+ function findLocalCatalog() {
46
+ let dir = process.cwd();
47
+ for (let i = 0; i < 8; i++) {
48
+ const f = join(dir, "hub", "catalog", "index.json");
49
+ if (existsSync(f))
50
+ return f;
51
+ const parent = dirname(dir);
52
+ if (parent === dir)
53
+ break;
54
+ dir = parent;
55
+ }
56
+ return null;
57
+ }
58
+ async function loadCatalog(from) {
59
+ const src = resolveCatalogSource(from, findLocalCatalog());
60
+ if (src.kind === "file") {
61
+ if (!existsSync(src.location))
62
+ fail(`catalog not found: ${src.location}`);
63
+ return JSON.parse(readFileSync(src.location, "utf8"));
64
+ }
65
+ const r = await fetch(src.location).catch((e) => fail(`fetch failed: ${String(e)}`));
66
+ if (!r.ok)
67
+ fail(`catalog HTTP ${r.status} from ${src.location}`);
68
+ return (await r.json());
69
+ }
70
+ async function plugin(sub, args, flags) {
71
+ const from = typeof flags.from === "string" ? flags.from : undefined;
72
+ const json = flags.json === true;
73
+ // `verify` operates on a local dir — no catalog needed (works offline).
74
+ if (sub === "verify") {
75
+ const dir = args[0];
76
+ if (!dir)
77
+ fail("usage: omcp plugin verify <dir> --trust-root <pem>");
78
+ const abs = resolve(dir);
79
+ if (!existsSync(abs))
80
+ fail(`directory not found: ${abs}`);
81
+ verifyPluginDir(abs, flags);
82
+ return;
83
+ }
84
+ const cat = await loadCatalog(from);
85
+ if (sub === "list") {
86
+ console.log(json ? JSON.stringify(cat, null, 2) : formatPluginList(cat));
87
+ return;
88
+ }
89
+ if (sub === "info") {
90
+ const name = args[0];
91
+ if (!name)
92
+ fail("usage: omcp plugin info <name>");
93
+ const c = cat.connectors.find((x) => x.name === name);
94
+ if (!c)
95
+ fail(`no connector '${name}' in catalog (try: omcp plugin list)`);
96
+ console.log(json ? JSON.stringify(c, null, 2) : formatPluginInfo(c));
97
+ return;
98
+ }
99
+ if (sub === "install") {
100
+ return installPlugin(cat, args[0], flags);
101
+ }
102
+ fail(`unknown 'plugin' subcommand: ${sub ?? "(none)"} (list|info|install|verify)`);
103
+ }
104
+ function tarExtract(tgz, dest) {
105
+ const r = spawnSync("tar", ["-xzf", tgz, "-C", dest], { stdio: "inherit" });
106
+ if (r.status !== 0)
107
+ fail(`tar extraction failed for ${tgz}`);
108
+ }
109
+ // Find the dir containing a package.json with the observabilityMcp
110
+ // connector marker (npm pack nests under package/; airgapped tarballs
111
+ // may not).
112
+ function findPluginRoot(base) {
113
+ const candidates = [base, ...readdirSync(base).map((e) => join(base, e))];
114
+ for (const dir of candidates) {
115
+ try {
116
+ if (!statSync(dir).isDirectory())
117
+ continue;
118
+ const pkgPath = join(dir, "package.json");
119
+ if (!existsSync(pkgPath))
120
+ continue;
121
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
122
+ if (pkg.observabilityMcp?.kind === "connector")
123
+ return dir;
124
+ }
125
+ catch {
126
+ /* skip */
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ // Shared fail-closed verification of an extracted/installed plugin dir.
132
+ // Used by `plugin install` and `plugin verify`. --insecure explicitly
133
+ // opts out; otherwise --trust-root is mandatory.
134
+ function verifyPluginDir(root, flags) {
135
+ const pkgPath = join(root, "package.json");
136
+ if (!existsSync(pkgPath))
137
+ fail(`no package.json in ${root}`);
138
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
139
+ if (pkg.observabilityMcp?.kind !== "connector")
140
+ fail("not a connector (observabilityMcp marker)");
141
+ const manifestRel = pkg.observabilityMcp?.manifest;
142
+ if (!manifestRel)
143
+ fail("package.json has no observabilityMcp.manifest");
144
+ const manifestPath = resolve(root, manifestRel);
145
+ if (!existsSync(manifestPath))
146
+ fail(`manifest not found: ${manifestRel}`);
147
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
148
+ const entryPath = resolve(root, pkg.main || "index.js");
149
+ if (flags.insecure === true) {
150
+ console.warn("WARNING: --insecure — skipping signature + integrity verification.");
151
+ return;
152
+ }
153
+ const trustRootPath = typeof flags["trust-root"] === "string" ? flags["trust-root"] : undefined;
154
+ if (!trustRootPath) {
155
+ fail("verification required: pass --trust-root <pem> (or --insecure to explicitly opt out)");
156
+ }
157
+ const sigPath = manifestPath + ".sig";
158
+ if (!existsSync(sigPath))
159
+ fail(`missing manifest signature: ${manifestRel}.sig`);
160
+ try {
161
+ const trustRoot = loadTrustRoot(trustRootPath);
162
+ verifyManifestSignature(readFileSync(manifestPath), readFileSync(sigPath), trustRoot);
163
+ verifyIntegrity(entryPath, manifest.integrity);
164
+ }
165
+ catch (e) {
166
+ const msg = e instanceof PluginVerificationError ? e.message : String(e);
167
+ fail(`verification failed (fail-closed): ${msg}`);
168
+ }
169
+ console.log(`signature + integrity OK (${pkg.observabilityMcp.name}@${manifest.version ?? "?"})`);
170
+ }
171
+ async function installPlugin(cat, ref, flags) {
172
+ if (!ref)
173
+ fail("usage: omcp plugin install <name>[@version]");
174
+ let r;
175
+ try {
176
+ r = resolveInstall(cat, ref);
177
+ }
178
+ catch (e) {
179
+ fail(e instanceof Error ? e.message : String(e));
180
+ }
181
+ if (r.builtin) {
182
+ console.log(`'${r.name}' is builtin — it ships in the server image, no install needed.`);
183
+ return;
184
+ }
185
+ const offlineDir = typeof flags["offline-dir"] === "string" ? flags["offline-dir"] : undefined;
186
+ const dest = resolve(typeof flags.dest === "string" ? flags.dest : process.env.PLUGINS_DIR ?? "./plugins");
187
+ const targetDir = join(dest, r.name);
188
+ if (existsSync(targetDir) && flags.force !== true) {
189
+ fail(`${targetDir} already exists (pass --force to overwrite)`);
190
+ }
191
+ const work = mkdtempSync(join(tmpdir(), "omcp-inst-"));
192
+ const tgz = join(work, "plugin.tgz");
193
+ if (offlineDir) {
194
+ const local = join(offlineDir, `${r.name}-${r.version}.tgz`);
195
+ if (!existsSync(local))
196
+ fail(`offline tarball not found: ${local}`);
197
+ cpSync(local, tgz);
198
+ }
199
+ else {
200
+ if (!r.tarballUrl)
201
+ fail(`catalog entry for ${r.name}@${r.version} has no tarballUrl`);
202
+ const resp = await fetch(r.tarballUrl).catch((e) => fail(`download failed: ${String(e)}`));
203
+ if (!resp.ok)
204
+ fail(`tarball HTTP ${resp.status}`);
205
+ writeFileSync(tgz, Buffer.from(await resp.arrayBuffer()));
206
+ }
207
+ const stage = join(work, "stage");
208
+ mkdirSync(stage);
209
+ tarExtract(tgz, stage);
210
+ const root = findPluginRoot(stage);
211
+ if (!root)
212
+ fail("no connector package.json (observabilityMcp marker) in tarball");
213
+ verifyPluginDir(root, flags);
214
+ mkdirSync(dest, { recursive: true });
215
+ if (existsSync(targetDir))
216
+ rmSync(targetDir, { recursive: true, force: true });
217
+ cpSync(root, targetDir, { recursive: true });
218
+ rmSync(work, { recursive: true, force: true });
219
+ console.log(`installed ${r.name}@${r.version} → ${targetDir}`);
220
+ }
221
+ function portInUse(port, host = "127.0.0.1") {
222
+ return new Promise((resolve) => {
223
+ const s = createConnection({ port, host });
224
+ const done = (v) => {
225
+ s.destroy();
226
+ resolve(v);
227
+ };
228
+ s.setTimeout(400);
229
+ s.once("connect", () => done(true));
230
+ s.once("timeout", () => done(false));
231
+ s.once("error", () => done(false));
232
+ });
233
+ }
234
+ function fail(msg) {
235
+ console.error("error: " + msg);
236
+ process.exit(1);
237
+ }
238
+ async function doctor(json) {
239
+ const checks = {
240
+ node: process.version,
241
+ docker: which("docker"),
242
+ "docker compose": dockerComposeVersion(),
243
+ helm: which("helm", ["version", "--short"]),
244
+ "compose file": findComposeFile() ?? null,
245
+ };
246
+ if (json) {
247
+ console.log(JSON.stringify(checks, null, 2));
248
+ }
249
+ else {
250
+ for (const [k, v] of Object.entries(checks)) {
251
+ const ok = v != null;
252
+ console.log(`${ok ? "ok " : "MISS"} ${k.padEnd(16)} ${ok ? v : "(not found)"}`);
253
+ }
254
+ }
255
+ const required = ["docker", "docker compose"];
256
+ if (required.some((k) => checks[k] == null)) {
257
+ fail("missing required tooling (docker + docker compose)");
258
+ }
259
+ }
260
+ async function demo(sub) {
261
+ const compose = findComposeFile();
262
+ if (!compose)
263
+ fail("docker-compose.yml not found (run from an observability-mcp checkout)");
264
+ const root = dirname(compose);
265
+ const baseArgs = ["compose", "-f", compose];
266
+ if (sub === "status") {
267
+ run("docker", [...baseArgs, "--profile", "demo", "ps"], root);
268
+ return;
269
+ }
270
+ if (sub === "down") {
271
+ run("docker", [...baseArgs, "--profile", "demo", "down", "-v"], root);
272
+ return;
273
+ }
274
+ if (sub !== "up")
275
+ fail(`unknown 'demo' subcommand: ${sub ?? "(none)"} (up|down|status)`);
276
+ // Auto-pick free host ports for the two services that commonly clash.
277
+ const wanted = [
278
+ { service: "mcp-server", container: 3000 },
279
+ { service: "loki", container: 3100 },
280
+ ];
281
+ const remaps = [];
282
+ for (const w of wanted) {
283
+ const busy = await portInUse(w.container);
284
+ let host = w.container;
285
+ if (busy) {
286
+ const used = new Set();
287
+ host = pickFreePort(w.container + 1, (p) => used.has(p));
288
+ // Probe sequentially; mark scanned-busy ports so pickFreePort skips.
289
+ for (let p = w.container + 1; p < w.container + 50; p++) {
290
+ if (await portInUse(p))
291
+ used.add(p);
292
+ else {
293
+ host = p;
294
+ break;
295
+ }
296
+ }
297
+ console.log(`port ${w.container} busy → ${w.service} mapped to host ${host}`);
298
+ }
299
+ remaps.push({ service: w.service, host, container: w.container });
300
+ }
301
+ const args = [...baseArgs];
302
+ const mcp = remaps.find((r) => r.service === "mcp-server");
303
+ const needsOverride = remaps.some((r) => r.host !== r.container);
304
+ if (needsOverride) {
305
+ const dir = mkdtempSync(join(tmpdir(), "omcp-"));
306
+ const ovr = join(dir, "override.yml");
307
+ writeFileSync(ovr, composeOverride(remaps));
308
+ args.push("-f", ovr);
309
+ }
310
+ args.push("--profile", "demo", "up", "--build", "-d");
311
+ const code = run("docker", args, root);
312
+ if (code === 0) {
313
+ console.log(`\ndemo stack up. Web UI / MCP: http://localhost:${mcp.host} (/mcp, /healthz)`);
314
+ console.log("Stop with: omcp demo down");
315
+ }
316
+ process.exit(code);
317
+ }
318
+ function helm(sub, release, passthrough) {
319
+ if (sub !== "install" && sub !== "upgrade") {
320
+ fail(`unknown 'helm' subcommand: ${sub ?? "(none)"} (install|upgrade)`);
321
+ }
322
+ if (!which("helm", ["version", "--short"])) {
323
+ fail("helm not found on PATH (see: https://helm.sh/docs/intro/install/)");
324
+ }
325
+ const cwd = process.cwd();
326
+ // Idempotent: --force-update tolerates an existing repo entry.
327
+ if (run("helm", ["repo", "add", HELM_REPO_NAME, HELM_REPO_URL, "--force-update"], cwd) !== 0) {
328
+ fail("helm repo add failed");
329
+ }
330
+ if (run("helm", ["repo", "update", HELM_REPO_NAME], cwd) !== 0) {
331
+ fail("helm repo update failed");
332
+ }
333
+ const args = helmReleaseArgs(sub, release, passthrough);
334
+ const code = run("helm", args, cwd);
335
+ if (code === 0) {
336
+ console.log(`\nhelm ${sub} ok: release '${release}' from the signed ${HELM_REPO_NAME} chart.`);
337
+ }
338
+ process.exit(code);
339
+ }
340
+ function run(cmd, args, cwd) {
341
+ const r = spawnSync(cmd, args, { cwd, stdio: "inherit" });
342
+ return r.status ?? 1;
343
+ }
344
+ async function main() {
345
+ const { argv: pre, passthrough } = splitPassthrough(process.argv.slice(2));
346
+ const { command, sub, flags, positionals } = parseArgs(pre);
347
+ const json = flags.json === true;
348
+ switch (command) {
349
+ case "":
350
+ case "help":
351
+ case "--help":
352
+ console.log(HELP);
353
+ return;
354
+ case "version":
355
+ case "--version":
356
+ console.log(`omcp ${pkgVersion()}`);
357
+ return;
358
+ case "doctor":
359
+ return doctor(json);
360
+ case "demo":
361
+ return demo(sub);
362
+ case "plugin":
363
+ return plugin(sub, positionals, flags);
364
+ case "helm":
365
+ return helm(sub, positionals[0] ?? "observability-mcp", passthrough);
366
+ default:
367
+ fail(`unknown command: ${command}\n\n${HELP}`);
368
+ }
369
+ }
370
+ main().catch((e) => fail(e instanceof Error ? e.message : String(e)));
@@ -0,0 +1,95 @@
1
+ export interface ParsedArgs {
2
+ command: string;
3
+ sub?: string;
4
+ flags: Record<string, string | boolean>;
5
+ positionals: string[];
6
+ }
7
+ /** Minimal argv parser: `omcp <command> [sub] [positionals] [--flag[=val]] [-f val]`. */
8
+ export declare function parseArgs(argv: string[]): ParsedArgs;
9
+ /**
10
+ * Given a desired port and a predicate that says whether a port is in
11
+ * use, return the first free port at or after `desired` (bounded scan).
12
+ */
13
+ export declare function pickFreePort(desired: number, inUse: (p: number) => boolean, span?: number): number;
14
+ /**
15
+ * Build a docker-compose override that remaps the host side of the
16
+ * given service ports. Uses the `!override` tag so it replaces (not
17
+ * appends to) the base `ports:` list.
18
+ */
19
+ export declare function composeOverride(remaps: Array<{
20
+ service: string;
21
+ host: number;
22
+ container: number;
23
+ }>): string;
24
+ export declare const DEFAULT_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
25
+ export interface CatalogVersion {
26
+ version: string;
27
+ releasedAt?: string;
28
+ serverCompat?: string;
29
+ tarballUrl?: string;
30
+ signatureUrl?: string;
31
+ manifestUrl?: string;
32
+ integrity?: string;
33
+ changelog?: string;
34
+ }
35
+ export interface CatalogConnector {
36
+ name: string;
37
+ displayName: string;
38
+ description: string;
39
+ tier: string;
40
+ builtin?: boolean;
41
+ signalTypes: string[];
42
+ latest?: string;
43
+ versions: CatalogVersion[];
44
+ }
45
+ export interface Catalog {
46
+ catalogVersion: number;
47
+ connectors: CatalogConnector[];
48
+ }
49
+ /**
50
+ * Decide where to read the catalog from, in priority order:
51
+ * 1. explicit `from` (a URL or a filesystem path)
52
+ * 2. a local checkout's hub/catalog/index.json (when localPath exists)
53
+ * 3. the public Pages catalog
54
+ */
55
+ export declare function resolveCatalogSource(from: string | undefined, localPath: string | null): {
56
+ kind: "url" | "file";
57
+ location: string;
58
+ };
59
+ export declare function formatPluginList(cat: Catalog): string;
60
+ export declare function formatPluginInfo(c: CatalogConnector): string;
61
+ /** Split "name" or "name@1.2.3" into parts. Throws on a malformed ref. */
62
+ export declare function parsePluginRef(ref: string): {
63
+ name: string;
64
+ version?: string;
65
+ };
66
+ export interface ResolvedInstall {
67
+ name: string;
68
+ version: string;
69
+ builtin: boolean;
70
+ tarballUrl?: string;
71
+ signatureUrl?: string;
72
+ manifestUrl?: string;
73
+ integrity?: string;
74
+ }
75
+ /**
76
+ * Resolve a catalog + ref into the concrete artifact to install.
77
+ * Returns {builtin:true} for image-bundled connectors (caller should
78
+ * no-op). Throws if the connector/version is unknown.
79
+ */
80
+ export declare function resolveInstall(cat: Catalog, ref: string): ResolvedInstall;
81
+ export declare const HELM_REPO_NAME = "observability-mcp";
82
+ export declare const HELM_REPO_URL = "https://thotischner.github.io/observability-mcp/";
83
+ export declare const HELM_CHART = "observability-mcp/observability-mcp";
84
+ /**
85
+ * Split argv at the first standalone "--": everything after it is
86
+ * passed verbatim to the wrapped tool (helm). Keeps omcp from having to
87
+ * re-implement helm's flag grammar (repeatable --set, -n, -f, ...).
88
+ */
89
+ export declare function splitPassthrough(argv: string[]): {
90
+ argv: string[];
91
+ passthrough: string[];
92
+ };
93
+ /** The helm argv for the install/upgrade step (repo add/update are fixed). */
94
+ export declare function helmReleaseArgs(action: "install" | "upgrade", release: string, passthrough: string[]): string[];
95
+ export declare const HELP = "omcp \u2014 observability-mcp control CLI\n\nUsage:\n omcp version Print CLI + server package version\n omcp doctor Check the local toolchain (docker, compose, helm, node)\n omcp demo up Start the full demo stack (auto-picks free host ports)\n omcp demo down Stop and remove the demo stack\n omcp demo status Show demo container status\n omcp plugin list List connectors from the hub catalog\n omcp plugin info <name> Show one connector's versions + verification info\n omcp plugin install <ref> Install name[@version]: download, verify, extract\n omcp plugin verify <dir> Verify an installed plugin dir against a trust root\n omcp helm install [release] helm repo add+update, then install the signed chart\n omcp helm upgrade [release] Same, as 'helm upgrade --install'\n omcp help Show this help\n\nPass extra helm flags after a literal --, e.g.:\n omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090\n\nFlags:\n --json Machine-readable output (doctor, status, plugin)\n --from <url|path> Catalog source (default: local checkout or the public hub)\n --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>\n --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)\n --insecure Skip verification (NOT recommended; explicit opt-out)\n --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)\n --force Overwrite an existing install dir\n";
@@ -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 {};