@thotischner/observability-mcp 1.4.1 → 1.6.0

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 (47) 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/context.d.ts +27 -0
  21. package/dist/context.js +18 -0
  22. package/dist/enterprise-gate.d.ts +132 -0
  23. package/dist/enterprise-gate.js +510 -0
  24. package/dist/enterprise-gate.test.d.ts +1 -0
  25. package/dist/enterprise-gate.test.js +178 -0
  26. package/dist/index.js +125 -44
  27. package/dist/net/egress-policy.d.ts +31 -0
  28. package/dist/net/egress-policy.js +37 -0
  29. package/dist/net/egress-policy.test.d.ts +1 -0
  30. package/dist/net/egress-policy.test.js +52 -0
  31. package/dist/tools/context-seam.test.d.ts +1 -0
  32. package/dist/tools/context-seam.test.js +23 -0
  33. package/dist/tools/detect-anomalies.d.ts +2 -1
  34. package/dist/tools/detect-anomalies.js +47 -11
  35. package/dist/tools/get-service-health.d.ts +2 -1
  36. package/dist/tools/get-service-health.js +13 -9
  37. package/dist/tools/handlers.test.js +104 -0
  38. package/dist/tools/list-services.d.ts +2 -1
  39. package/dist/tools/list-services.js +2 -1
  40. package/dist/tools/list-sources.d.ts +2 -1
  41. package/dist/tools/list-sources.js +2 -1
  42. package/dist/tools/query-logs.d.ts +2 -1
  43. package/dist/tools/query-logs.js +2 -1
  44. package/dist/tools/query-metrics.d.ts +2 -1
  45. package/dist/tools/query-metrics.js +9 -1
  46. package/dist/ui/index.html +1510 -67
  47. package/package.json +10 -2
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Single-tenant authentication primitive (opt-in, backward compatible).
3
+ *
4
+ * If no credentials are configured the server behaves exactly as before
5
+ * (anonymous, all access). If `OMCP_API_KEYS` is set, the `/mcp` endpoint
6
+ * requires a valid `Authorization: Bearer <token>` or `X-API-Key: <token>`.
7
+ *
8
+ * Config (env, no secrets in files):
9
+ * OMCP_API_KEYS="ci:tok_abc,agent:tok_def" # name:token, comma-separated
10
+ * (a bare "tok_xyz" is allowed; name defaults to "key")
11
+ * OMCP_KEY_SOURCES="agent=prom-prod|loki-prod;ci=prom-staging"
12
+ * # optional coarse per-key source allow-list
13
+ *
14
+ * Rich role-based access control (tools/services/lookback/read-only, the
15
+ * full governance object) is intentionally NOT here — this is only the
16
+ * authentication + identity + coarse source-scoping primitive.
17
+ */
18
+ function parseKeySources(raw) {
19
+ const m = new Map();
20
+ if (!raw)
21
+ return m;
22
+ for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
23
+ const [name, list] = entry.split("=");
24
+ if (!name || !list)
25
+ continue;
26
+ m.set(name.trim(), list.split("|").map((s) => s.trim()).filter(Boolean));
27
+ }
28
+ return m;
29
+ }
30
+ /** Parse credentials from env. Returns an empty list when unconfigured. */
31
+ export function loadCredentials(env = process.env) {
32
+ const raw = env.OMCP_API_KEYS?.trim();
33
+ if (!raw)
34
+ return [];
35
+ const keySources = parseKeySources(env.OMCP_KEY_SOURCES);
36
+ const creds = [];
37
+ for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
38
+ const idx = part.indexOf(":");
39
+ const name = idx > 0 ? part.slice(0, idx).trim() : "key";
40
+ const token = (idx > 0 ? part.slice(idx + 1) : part).trim();
41
+ if (!token)
42
+ continue;
43
+ creds.push({ name, token, allowedSources: keySources.get(name) });
44
+ }
45
+ return creds;
46
+ }
47
+ export function credentialsConfigured(env = process.env) {
48
+ return loadCredentials(env).length > 0;
49
+ }
50
+ /** Extract a bearer/api-key token from request headers. */
51
+ export function extractToken(headers) {
52
+ const auth = headers["authorization"];
53
+ if (typeof auth === "string" && /^Bearer\s+/i.test(auth)) {
54
+ return auth.replace(/^Bearer\s+/i, "").trim() || null;
55
+ }
56
+ const apiKey = headers["x-api-key"];
57
+ if (typeof apiKey === "string" && apiKey.trim())
58
+ return apiKey.trim();
59
+ return null;
60
+ }
61
+ /** Constant-time-ish token match → resolved credential, or null. */
62
+ export function resolveToken(token, creds) {
63
+ if (!token)
64
+ return null;
65
+ for (const c of creds) {
66
+ if (c.token.length === token.length && safeEqual(c.token, token))
67
+ return c;
68
+ }
69
+ return null;
70
+ }
71
+ function safeEqual(a, b) {
72
+ let diff = 0;
73
+ for (let i = 0; i < a.length; i++)
74
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
75
+ return diff === 0;
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./credentials.js";
4
+ import { queryMetricsHandler } from "../tools/query-metrics.js";
5
+ import { principalContext, defaultContext } from "../context.js";
6
+ describe("single-tenant auth primitive", () => {
7
+ it("unconfigured → no credentials, anonymous (backward compatible)", () => {
8
+ assert.equal(credentialsConfigured({}), false);
9
+ assert.deepEqual(loadCredentials({}), []);
10
+ });
11
+ it("parses name:token and bare token", () => {
12
+ const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
13
+ assert.equal(creds.length, 2);
14
+ assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined });
15
+ assert.equal(creds[1].name, "key");
16
+ assert.equal(creds[1].token, "tok_bare");
17
+ });
18
+ it("parses per-key source allow-list", () => {
19
+ const creds = loadCredentials({
20
+ OMCP_API_KEYS: "agent:tok1,ci:tok2",
21
+ OMCP_KEY_SOURCES: "agent=prom-prod|loki-prod; ci=prom-staging",
22
+ });
23
+ assert.deepEqual(creds[0].allowedSources, ["prom-prod", "loki-prod"]);
24
+ assert.deepEqual(creds[1].allowedSources, ["prom-staging"]);
25
+ });
26
+ it("extractToken handles Bearer and X-API-Key", () => {
27
+ assert.equal(extractToken({ authorization: "Bearer abc" }), "abc");
28
+ assert.equal(extractToken({ authorization: "bearer xyz " }), "xyz");
29
+ assert.equal(extractToken({ "x-api-key": "k1" }), "k1");
30
+ assert.equal(extractToken({}), null);
31
+ });
32
+ it("resolveToken matches only an exact token", () => {
33
+ const creds = loadCredentials({ OMCP_API_KEYS: "a:secret123" });
34
+ assert.equal(resolveToken("secret123", creds)?.name, "a");
35
+ assert.equal(resolveToken("secret12", creds), null);
36
+ assert.equal(resolveToken("wrong", creds), null);
37
+ assert.equal(resolveToken(null, creds), null);
38
+ });
39
+ it("coarse source scoping denies an out-of-scope source", async () => {
40
+ const ctx = principalContext("agent", ["prom-prod"]);
41
+ const res = await queryMetricsHandler({}, { service: "svc", metric: "cpu", source: "prom-secret" }, ctx);
42
+ const text = res.content[0].text;
43
+ assert.match(text, /forbidden: source.*prom-secret.*not in your allowed sources/);
44
+ });
45
+ it("anonymous (no allow-list) does not trigger the scoping guard", async () => {
46
+ // No allowedSources → guard is a no-op. It must NOT short-circuit with a
47
+ // forbidden message (it falls through to normal handling, which on a stub
48
+ // registry may throw — that's fine, it means we passed the guard).
49
+ try {
50
+ const res = await queryMetricsHandler({}, { service: "svc", metric: "cpu", source: "anything" }, defaultContext());
51
+ assert.doesNotMatch(res.content[0].text, /allowed sources/);
52
+ }
53
+ catch {
54
+ // threw past the guard → guard correctly did not fire
55
+ }
56
+ });
57
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Request-scoped context threaded from the transport boundary (HTTP `/mcp`,
3
+ * stdio, and the internal REST/dashboard call sites) into every tool handler.
4
+ *
5
+ * Today it carries only an anonymous principal and a correlation id — it is a
6
+ * deliberate pass-through that does not change behaviour. It is the single
7
+ * seam that later access-control / scoping / audit work attaches to, so those
8
+ * features become additive rather than a cross-cutting rewrite.
9
+ */
10
+ export interface RequestContext {
11
+ /** Stable id for the calling principal. "anonymous" when no auth configured. */
12
+ principalId: string;
13
+ /** How the principal was authenticated. */
14
+ auth: "anonymous" | "apikey";
15
+ /**
16
+ * Coarse per-credential source allow-list (single-tenant primitive). When
17
+ * set, the principal may only target these source names. Rich role-based
18
+ * scoping (tools/services/lookback/read-only) is a separate concern.
19
+ */
20
+ allowedSources?: string[];
21
+ /** Correlates all tool calls within one transport request/session. */
22
+ correlationId: string;
23
+ }
24
+ /** Default all-access anonymous context — preserves current behaviour. */
25
+ export declare function defaultContext(): RequestContext;
26
+ /** Context for an authenticated API-key principal. */
27
+ export declare function principalContext(principalId: string, allowedSources?: string[]): RequestContext;
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** Default all-access anonymous context — preserves current behaviour. */
3
+ export function defaultContext() {
4
+ return {
5
+ principalId: "anonymous",
6
+ auth: "anonymous",
7
+ correlationId: randomUUID(),
8
+ };
9
+ }
10
+ /** Context for an authenticated API-key principal. */
11
+ export function principalContext(principalId, allowedSources) {
12
+ return {
13
+ principalId,
14
+ auth: "apikey",
15
+ allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
16
+ correlationId: randomUUID(),
17
+ };
18
+ }
@@ -0,0 +1,132 @@
1
+ import type { RequestContext } from "./context.js";
2
+ export interface ToolRequest {
3
+ tool: string;
4
+ source?: string;
5
+ service?: string;
6
+ }
7
+ type GateState = {
8
+ mode: "off";
9
+ } | {
10
+ mode: "fail-closed";
11
+ reason: string;
12
+ } | {
13
+ mode: "active";
14
+ claims: Record<string, unknown>;
15
+ accessControl: boolean;
16
+ enforceRbac?: (policy: unknown, ctx: unknown, req: unknown) => unknown;
17
+ enforceCatalog?: (catalog: unknown, ctx: unknown, req: unknown) => unknown;
18
+ rbacPolicy?: unknown;
19
+ catalog?: unknown;
20
+ audit?: {
21
+ record: (e: unknown) => Promise<unknown>;
22
+ };
23
+ };
24
+ /** Tests only: also drops the audit singleton for full isolation. */
25
+ export declare function _resetEnterpriseAudit(): void;
26
+ /** Reset memoised state (tests only). */
27
+ export declare function _resetEnterpriseGate(): void;
28
+ /** Gate mode — for diagnostics (/api/info). */
29
+ export declare function enterpriseGateStatus(): Promise<{
30
+ active: boolean;
31
+ mode: GateState["mode"];
32
+ reason?: string;
33
+ }>;
34
+ /**
35
+ * The single enforcement point, called before every MCP tool runs.
36
+ *
37
+ * off: no opt-in, no entitlement → memoised no-op, returns
38
+ * immediately. Zero behaviour change for the OSS core;
39
+ * the only path the published artifact ever takes.
40
+ * fail-closed: a control was configured but the gate could not be
41
+ * activated → deny EVERY tool call (a broken/expired
42
+ * entitlement must never silently disable enforcement).
43
+ * active: record the decision (if audit entitled) and deny by
44
+ * throwing — the MCP SDK turns the throw into a clean tool
45
+ * error and the handler never runs.
46
+ */
47
+ export declare function enforceEntitledAccess(ctx: RequestContext, request: ToolRequest): Promise<void>;
48
+ export interface EnterpriseGateInfo {
49
+ mode: GateState["mode"];
50
+ active: boolean;
51
+ reason?: string;
52
+ rbacConfigured: boolean;
53
+ catalogConfigured: boolean;
54
+ auditConfigured: boolean;
55
+ entitlement: Record<string, unknown> | null;
56
+ }
57
+ export declare function enterpriseGateInfo(): Promise<EnterpriseGateInfo>;
58
+ /** The loaded RBAC policy (read-only view). */
59
+ export declare function enterprisePolicyView(): {
60
+ configured: false;
61
+ data?: undefined;
62
+ error?: undefined;
63
+ } | {
64
+ configured: true;
65
+ data: any;
66
+ error?: undefined;
67
+ } | {
68
+ configured: true;
69
+ error: string;
70
+ data?: undefined;
71
+ };
72
+ /** The loaded product catalog (read-only view). */
73
+ export declare function enterpriseCatalogView(): {
74
+ configured: false;
75
+ data?: undefined;
76
+ error?: undefined;
77
+ } | {
78
+ configured: true;
79
+ data: any;
80
+ error?: undefined;
81
+ } | {
82
+ configured: true;
83
+ error: string;
84
+ data?: undefined;
85
+ };
86
+ /** Recent audit decisions + a tamper-evidence check over the whole log. */
87
+ export declare function enterpriseAuditTail(limit?: number): Promise<{
88
+ configured: false;
89
+ error?: undefined;
90
+ total?: undefined;
91
+ chain?: undefined;
92
+ entries?: undefined;
93
+ } | {
94
+ configured: true;
95
+ error: string;
96
+ total?: undefined;
97
+ chain?: undefined;
98
+ entries?: undefined;
99
+ } | {
100
+ configured: true;
101
+ total: number;
102
+ chain: unknown;
103
+ entries: unknown[];
104
+ error?: undefined;
105
+ }>;
106
+ export declare const ADMIN_CAP = "enterprise:admin";
107
+ /** Structural validation — never trust a PUT body. */
108
+ export declare function validatePolicyShape(p: any): string | null;
109
+ export interface AdminResult {
110
+ ok: boolean;
111
+ status: number;
112
+ error?: string;
113
+ }
114
+ /**
115
+ * Authorize an admin action for `principalId` against the CURRENT
116
+ * on-disk policy (read fresh, never the memoised copy).
117
+ */
118
+ export declare function authorizeAdmin(principalId: string | null): Promise<AdminResult>;
119
+ /**
120
+ * Replace the RBAC policy. Caller must have passed authorizeAdmin first.
121
+ * Validates, blocks self-lockout, writes atomically, audits, and
122
+ * invalidates the gate memo so enforcement picks up the new policy.
123
+ */
124
+ export declare function updateRbacPolicy(principalId: string, next: unknown): Promise<AdminResult>;
125
+ /** Structural validation for a product catalog PUT body. */
126
+ export declare function validateCatalogShape(c: any): string | null;
127
+ /**
128
+ * Replace the product catalog. Caller must have passed authorizeAdmin.
129
+ * Validates, writes atomically, audits, invalidates the gate memo.
130
+ */
131
+ export declare function updateCatalog(principalId: string, next: unknown): Promise<AdminResult>;
132
+ export {};