@thotischner/observability-mcp 1.8.1 → 3.0.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 (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -0,0 +1,206 @@
1
+ // MCP 2025-11-25 conformance harness.
2
+ //
3
+ // Run against a running gateway by setting OMCP_CONFORMANCE_URL to
4
+ // its Streamable HTTP endpoint (default http://localhost:3000/mcp).
5
+ // When the env var is unset, every test skips — this lets the suite
6
+ // live in `find src -name "*.test.ts"` without requiring a server
7
+ // during a plain unit-test run.
8
+ //
9
+ // OMCP_CONFORMANCE_URL=http://localhost:3000/mcp \
10
+ // npx tsx --test src/conformance/mcp-2025-11-25.test.ts
11
+ //
12
+ // The `make conformance` target boots the demo stack, waits for
13
+ // /healthz, then runs this file with the URL pointed at the live
14
+ // server.
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ const URL_ENV = process.env.OMCP_CONFORMANCE_URL;
18
+ const PROTOCOL_VERSION = "2025-11-25";
19
+ const skip = !URL_ENV;
20
+ const opts = skip ? { skip: "OMCP_CONFORMANCE_URL not set" } : {};
21
+ async function jsonRpc(method, params, opts = {}) {
22
+ if (!URL_ENV)
23
+ throw new Error("OMCP_CONFORMANCE_URL not set");
24
+ const reqHeaders = {
25
+ "content-type": "application/json",
26
+ accept: "application/json, text/event-stream",
27
+ };
28
+ if (opts.session)
29
+ reqHeaders["mcp-session-id"] = opts.session;
30
+ const body = {
31
+ jsonrpc: "2.0",
32
+ id: opts.id ?? 1,
33
+ method,
34
+ params: params ?? {},
35
+ };
36
+ const res = await fetch(URL_ENV, {
37
+ method: "POST",
38
+ headers: reqHeaders,
39
+ body: JSON.stringify(body),
40
+ });
41
+ const headers = {};
42
+ res.headers.forEach((v, k) => {
43
+ headers[k] = v;
44
+ });
45
+ // Streamable HTTP may answer with either JSON or SSE; both carry a
46
+ // single JSON-RPC envelope for unary calls. Strip the SSE framing
47
+ // if present so the test only deals with the JSON shape.
48
+ const text = await res.text();
49
+ let response;
50
+ if (text.startsWith("event:") || text.includes("data: ")) {
51
+ const match = text.match(/^data:\s*(.+)$/m);
52
+ response = match ? JSON.parse(match[1]) : {};
53
+ }
54
+ else if (text.trim().startsWith("{")) {
55
+ response = JSON.parse(text);
56
+ }
57
+ else {
58
+ response = {};
59
+ }
60
+ return { response, headers, status: res.status };
61
+ }
62
+ async function notify(method, session) {
63
+ if (!URL_ENV)
64
+ return;
65
+ await fetch(URL_ENV, {
66
+ method: "POST",
67
+ headers: {
68
+ "content-type": "application/json",
69
+ accept: "application/json, text/event-stream",
70
+ "mcp-session-id": session,
71
+ },
72
+ body: JSON.stringify({ jsonrpc: "2.0", method, params: {} }),
73
+ });
74
+ }
75
+ async function newSession() {
76
+ const { headers, response } = await jsonRpc("initialize", {
77
+ protocolVersion: PROTOCOL_VERSION,
78
+ capabilities: {},
79
+ clientInfo: { name: "conformance-harness", version: "0" },
80
+ }, { id: 1 });
81
+ assert.ok(response.result, "initialize must return a result");
82
+ const session = headers["mcp-session-id"];
83
+ assert.ok(session, "server must issue mcp-session-id on initialize");
84
+ await notify("notifications/initialized", session);
85
+ return session;
86
+ }
87
+ test("MCP 2025-11-25: initialize returns spec-compliant InitializeResult", opts, async () => {
88
+ const { response, headers } = await jsonRpc("initialize", {
89
+ protocolVersion: PROTOCOL_VERSION,
90
+ capabilities: {},
91
+ clientInfo: { name: "harness", version: "0" },
92
+ });
93
+ assert.equal(response.jsonrpc, "2.0");
94
+ assert.equal(response.id, 1);
95
+ assert.ok(response.result && typeof response.result === "object");
96
+ const r = response.result;
97
+ assert.ok(r.protocolVersion, "InitializeResult must include protocolVersion");
98
+ assert.ok(r.capabilities && typeof r.capabilities === "object", "capabilities object required");
99
+ assert.ok(r.serverInfo && typeof r.serverInfo === "object", "serverInfo required");
100
+ assert.ok(r.serverInfo?.name, "serverInfo.name required");
101
+ assert.ok(r.serverInfo?.version, "serverInfo.version required");
102
+ assert.ok(headers["mcp-session-id"], "Mcp-Session-Id header required on initialize response");
103
+ });
104
+ test("MCP 2025-11-25: tools/list returns a Tool[] each with name + inputSchema", opts, async () => {
105
+ const session = await newSession();
106
+ const { response } = await jsonRpc("tools/list", {}, { id: 2, session });
107
+ assert.ok(response.result, JSON.stringify(response.error ?? {}));
108
+ const r = response.result;
109
+ assert.ok(Array.isArray(r.tools), "tools must be an array");
110
+ assert.ok(r.tools && r.tools.length > 0, "gateway must expose at least one tool");
111
+ for (const t of r.tools) {
112
+ assert.ok(t.name && typeof t.name === "string", `tool name required, got ${JSON.stringify(t)}`);
113
+ assert.ok(t.inputSchema && typeof t.inputSchema === "object", `tool ${t.name} missing inputSchema`);
114
+ }
115
+ });
116
+ test("MCP 2025-11-25: tools/call dispatches and returns CallToolResult", opts, async () => {
117
+ const session = await newSession();
118
+ const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: {} }, { id: 3, session });
119
+ // Either a result (success path) or a JSON-RPC error — both are
120
+ // spec-compliant; we just verify shape.
121
+ if (response.error) {
122
+ assert.equal(typeof response.error.code, "number");
123
+ assert.equal(typeof response.error.message, "string");
124
+ }
125
+ else {
126
+ const r = response.result;
127
+ assert.ok(Array.isArray(r.content), "CallToolResult.content must be an array");
128
+ }
129
+ });
130
+ test("MCP 2025-11-25: unknown method returns -32601 Method not found", opts, async () => {
131
+ const session = await newSession();
132
+ const { response } = await jsonRpc("this/method/does/not/exist", {}, { id: 99, session });
133
+ assert.ok(response.error, "expected an error envelope");
134
+ assert.equal(response.error?.code, -32601, "spec-mandated error code for unknown method");
135
+ });
136
+ test("MCP 2025-11-25: ping returns an empty result", opts, async () => {
137
+ const session = await newSession();
138
+ const { response } = await jsonRpc("ping", {}, { id: 4, session });
139
+ assert.ok(response.result !== undefined, "ping must return a result (may be empty object)");
140
+ });
141
+ test("MCP 2025-11-25: resources/list returns Resource[] or method-not-found", opts, async () => {
142
+ const session = await newSession();
143
+ const { response } = await jsonRpc("resources/list", {}, { id: 5, session });
144
+ if (response.error) {
145
+ assert.equal(response.error.code, -32601, "if not supported, must be -32601");
146
+ }
147
+ else {
148
+ const r = response.result;
149
+ assert.ok(Array.isArray(r.resources), "resources must be an array");
150
+ }
151
+ });
152
+ test("MCP 2025-11-25: prompts/list returns Prompt[] or method-not-found", opts, async () => {
153
+ const session = await newSession();
154
+ const { response } = await jsonRpc("prompts/list", {}, { id: 6, session });
155
+ if (response.error) {
156
+ assert.equal(response.error.code, -32601, "if not supported, must be -32601");
157
+ }
158
+ else {
159
+ const r = response.result;
160
+ assert.ok(Array.isArray(r.prompts), "prompts must be an array");
161
+ }
162
+ });
163
+ test("MCP 2025-11-25: logging/setLevel accepts spec levels or method-not-found", opts, async () => {
164
+ const session = await newSession();
165
+ const { response } = await jsonRpc("logging/setLevel", { level: "info" }, { id: 7, session });
166
+ if (response.error) {
167
+ assert.equal(response.error.code, -32601, "if not supported, must be -32601");
168
+ }
169
+ else {
170
+ // Spec says the result is `EmptyResult` — we don't enforce
171
+ // strictly empty (some implementations include diagnostics) but
172
+ // it must be a JSON object.
173
+ assert.ok(typeof response.result === "object");
174
+ }
175
+ });
176
+ test("MCP 2025-11-25: tools/call with invalid params returns -32602 or isError result", opts, async () => {
177
+ const session = await newSession();
178
+ const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: { __invalid_arg: { nested: 1 } } }, { id: 8, session });
179
+ // The spec allows either a JSON-RPC error or an isError CallToolResult.
180
+ // We accept either; reject only on a successful non-error result for
181
+ // input that should not validate.
182
+ if (response.error) {
183
+ assert.ok([-32602, -32600].includes(response.error.code) || response.error.code <= -32000);
184
+ }
185
+ else {
186
+ const r = response.result;
187
+ // list_sources happens to ignore unknown args — that's fine, the
188
+ // spec doesn't require strict input rejection for tools that opt
189
+ // out. Just confirm we got a shape-conformant CallToolResult.
190
+ assert.ok(Array.isArray(r.content));
191
+ }
192
+ });
193
+ test("MCP 2025-11-25: server advertises protocolVersion equal to or newer than 2025-11-25", opts, async () => {
194
+ const { response } = await jsonRpc("initialize", {
195
+ protocolVersion: PROTOCOL_VERSION,
196
+ capabilities: {},
197
+ clientInfo: { name: "harness", version: "0" },
198
+ }, { id: 100 });
199
+ const r = response.result;
200
+ assert.ok(r.protocolVersion, "protocolVersion must be present in InitializeResult");
201
+ // Spec contract: the server picks the highest version it supports
202
+ // that the client also offered, OR returns the highest it knows
203
+ // about and lets the client decide. We just require it's a
204
+ // recognised date-style version string.
205
+ assert.match(r.protocolVersion, /^\d{4}-\d{2}-\d{2}$/, "protocolVersion must be a YYYY-MM-DD date");
206
+ });
@@ -1,4 +1,4 @@
1
- import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
1
+ import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
2
2
  export interface ObservabilityConnector {
3
3
  readonly name: string;
4
4
  readonly type: string;
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
14
14
  listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
15
15
  queryMetrics?(params: MetricQuery): Promise<MetricResult>;
16
16
  queryLogs?(params: LogQuery): Promise<LogResult>;
17
+ /** Optional traces capability — Tempo / Jaeger / OTLP backends
18
+ * implement this. The MCP `query_traces` tool fans out to every
19
+ * connector that has it. */
20
+ queryTraces?(params: TraceQuery): Promise<TraceResult>;
17
21
  /** Current in-memory resource list. Should be O(1) — backed by the watch cache. */
18
22
  listResources?(): Promise<Resource[]>;
19
23
  /** Current in-memory edge list. Should be O(1) — backed by the watch cache. */
@@ -23,9 +23,11 @@ export class PluginLoader {
23
23
  pluginsDir;
24
24
  disabled;
25
25
  // Fail-closed verification for filesystem plugins. Builtins are part
26
- // of the trusted image and are never gated. Default off so existing
27
- // deployments are unchanged; recommended on in prod/airgapped (the
28
- // Helm chart sets it).
26
+ // of the trusted image and are never gated. Default ON operators
27
+ // who want to load unsigned filesystem plugins must opt out with
28
+ // VERIFY_PLUGINS=false. Without a trust root configured, no
29
+ // filesystem plugins load (only builtins), so the demo and any
30
+ // deployment without /app/plugins is unaffected.
29
31
  verify;
30
32
  trustRootPath;
31
33
  trustRoot;
@@ -39,7 +41,7 @@ export class PluginLoader {
39
41
  .map((s) => s.trim())
40
42
  .filter(Boolean);
41
43
  this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
42
- this.verify = opts.verify ?? /^(1|true|yes)$/i.test(process.env.VERIFY_PLUGINS ?? "");
44
+ this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
43
45
  this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
44
46
  }
45
47
  async load() {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { PluginLoader } from "./loader.js";
7
+ function tmp() {
8
+ return mkdtempSync(join(tmpdir(), "loader-default-"));
9
+ }
10
+ function withEnv(overrides, fn) {
11
+ const saved = {};
12
+ for (const k of Object.keys(overrides)) {
13
+ saved[k] = process.env[k];
14
+ if (overrides[k] === undefined)
15
+ delete process.env[k];
16
+ else
17
+ process.env[k] = overrides[k];
18
+ }
19
+ try {
20
+ fn();
21
+ }
22
+ finally {
23
+ for (const k of Object.keys(saved)) {
24
+ if (saved[k] === undefined)
25
+ delete process.env[k];
26
+ else
27
+ process.env[k] = saved[k];
28
+ }
29
+ }
30
+ }
31
+ test("PluginLoader: VERIFY_PLUGINS defaults to ON when env var unset", () => {
32
+ withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, () => {
33
+ const loader = new PluginLoader({ pluginsDir: tmp() });
34
+ assert.equal(loader["verify"], true, "verify default should be true (fail-closed)");
35
+ });
36
+ });
37
+ test("PluginLoader: VERIFY_PLUGINS=false opts out explicitly", () => {
38
+ withEnv({ VERIFY_PLUGINS: "false" }, () => {
39
+ const loader = new PluginLoader({ pluginsDir: tmp() });
40
+ assert.equal(loader["verify"], false);
41
+ });
42
+ });
43
+ test("PluginLoader: VERIFY_PLUGINS=0 / no / off also opt out", () => {
44
+ for (const v of ["0", "no", "off", "FALSE", "Off"]) {
45
+ withEnv({ VERIFY_PLUGINS: v }, () => {
46
+ const loader = new PluginLoader({ pluginsDir: tmp() });
47
+ assert.equal(loader["verify"], false, `value ${v} should disable verify`);
48
+ });
49
+ }
50
+ });
51
+ test("PluginLoader: VERIFY_PLUGINS=true / 1 / yes keep verify on", () => {
52
+ for (const v of ["true", "1", "yes", "TRUE", "Yes"]) {
53
+ withEnv({ VERIFY_PLUGINS: v }, () => {
54
+ const loader = new PluginLoader({ pluginsDir: tmp() });
55
+ assert.equal(loader["verify"], true);
56
+ });
57
+ }
58
+ });
59
+ test("PluginLoader: opts.verify overrides env var", () => {
60
+ withEnv({ VERIFY_PLUGINS: "false" }, () => {
61
+ const onLoader = new PluginLoader({ pluginsDir: tmp(), verify: true });
62
+ assert.equal(onLoader["verify"], true);
63
+ });
64
+ withEnv({ VERIFY_PLUGINS: "true" }, () => {
65
+ const offLoader = new PluginLoader({ pluginsDir: tmp(), verify: false });
66
+ assert.equal(offLoader["verify"], false);
67
+ });
68
+ });
69
+ test("PluginLoader.load(): with verify on + no trust root → builtins still load, filesystem skipped", async () => {
70
+ withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, async () => {
71
+ const loader = new PluginLoader({ pluginsDir: tmp() });
72
+ await loader.load();
73
+ const names = loader.supportedTypes();
74
+ assert.ok(names.includes("prometheus"), "prometheus builtin must remain available");
75
+ assert.ok(names.includes("loki"), "loki builtin must remain available");
76
+ assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
77
+ });
78
+ });
@@ -77,27 +77,45 @@ describe("PrometheusConnector", () => {
77
77
  });
78
78
  });
79
79
  describe("buildQuery", () => {
80
- it("replaces {{service}} placeholder in known metrics", async () => {
80
+ // buildQuery is private async and returns `{ promql, label, candidate }`.
81
+ // To keep these tests off the network, every case uses a user-
82
+ // override metric — that short-circuits the candidate-probe path
83
+ // (which would otherwise call Prometheus to pick the best variant
84
+ // and resolveServiceLabel to discover the right scoping label).
85
+ // The label / candidate fields are exercised via the public
86
+ // queryMetrics path elsewhere.
87
+ const fakeSource = { name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true };
88
+ it("replaces {{service}} placeholder in user-defined metrics", async () => {
81
89
  const connector = new PrometheusConnector();
82
- await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
83
- const query = proto.buildQuery.call(connector, "payment-service", "cpu");
84
- assert.ok(query.includes("payment-service"));
85
- assert.ok(!query.includes("{{service}}"));
86
- });
87
- it("falls back to generic query for unknown metrics", async () => {
90
+ await connector.connect({
91
+ ...fakeSource,
92
+ metrics: [{ name: "cpu", query: 'cpu_usage{svc="{{service}}"}', unit: "%", description: "CPU" }],
93
+ });
94
+ const { promql } = await proto.buildQuery.call(connector, "payment-service", "cpu");
95
+ assert.ok(promql.includes("payment-service"));
96
+ assert.ok(!promql.includes("{{service}}"));
97
+ });
98
+ it("respects an explicit {{service}} substitution outside the {{selector}} sugar", async () => {
99
+ // Different from the other two: the override here uses {{service}}
100
+ // directly inside a hand-written selector (no {{selector}} sugar).
101
+ // Confirms the substitution applies to the raw template, not only
102
+ // through the label-resolver path.
88
103
  const connector = new PrometheusConnector();
89
- await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
90
- const query = proto.buildQuery.call(connector, "my-svc", "unknown_metric");
91
- assert.equal(query, 'unknown_metric{job="my-svc"}');
104
+ await connector.connect({
105
+ ...fakeSource,
106
+ metrics: [{ name: "explicit_selector", query: 'explicit_metric{job="{{service}}"}', unit: "", description: "" }],
107
+ });
108
+ const { promql } = await proto.buildQuery.call(connector, "my-svc", "explicit_selector");
109
+ assert.equal(promql, 'explicit_metric{job="my-svc"}');
92
110
  });
93
111
  it("uses custom metrics from source config", async () => {
94
112
  const connector = new PrometheusConnector();
95
113
  await connector.connect({
96
- name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true,
114
+ ...fakeSource,
97
115
  metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
98
116
  });
99
- const query = proto.buildQuery.call(connector, "api", "custom");
100
- assert.equal(query, 'my_custom_metric{svc="api"}');
117
+ const { promql } = await proto.buildQuery.call(connector, "api", "custom");
118
+ assert.equal(promql, 'my_custom_metric{svc="api"}');
101
119
  });
102
120
  });
103
121
  });
@@ -17,5 +17,18 @@ export declare class ConnectorRegistry {
17
17
  getAll(): ObservabilityConnector[];
18
18
  getByName(name: string): ObservabilityConnector | undefined;
19
19
  getBySignal(signal: SignalType): ObservabilityConnector[];
20
+ /** Connectors visible to the named tenant: every source whose
21
+ * config.tenant matches OR is unset (global). Unset = available
22
+ * everywhere — keeps single-tenant deployments untouched.
23
+ * Anonymous traffic / the agent / internal callers can pass
24
+ * the DEFAULT_TENANT sentinel and see exactly what the default-
25
+ * tenant operator sees. */
26
+ getByTenant(tenant: string): ObservabilityConnector[];
27
+ /** Same as `getByName`, but enforces the tenant gate: a source
28
+ * whose config.tenant is set and differs from the calling tenant
29
+ * returns undefined — indistinguishable from "no such source",
30
+ * per the rest of the tenancy layer (no cross-tenant existence
31
+ * leak). Unset source tenant = global, always resolves. */
32
+ getByNameForTenant(name: string, tenant: string): ObservabilityConnector | undefined;
20
33
  healthCheckAll(): Promise<Record<string, ConnectorHealth>>;
21
34
  }
@@ -80,6 +80,36 @@ export class ConnectorRegistry {
80
80
  getBySignal(signal) {
81
81
  return this.getAll().filter((c) => c.signalType === signal);
82
82
  }
83
+ /** Connectors visible to the named tenant: every source whose
84
+ * config.tenant matches OR is unset (global). Unset = available
85
+ * everywhere — keeps single-tenant deployments untouched.
86
+ * Anonymous traffic / the agent / internal callers can pass
87
+ * the DEFAULT_TENANT sentinel and see exactly what the default-
88
+ * tenant operator sees. */
89
+ getByTenant(tenant) {
90
+ const out = [];
91
+ for (const [name, c] of this.connectors) {
92
+ const cfg = this.sourceConfigs.get(name);
93
+ const srcTenant = cfg?.tenant;
94
+ if (!srcTenant || srcTenant === tenant)
95
+ out.push(c);
96
+ }
97
+ return out;
98
+ }
99
+ /** Same as `getByName`, but enforces the tenant gate: a source
100
+ * whose config.tenant is set and differs from the calling tenant
101
+ * returns undefined — indistinguishable from "no such source",
102
+ * per the rest of the tenancy layer (no cross-tenant existence
103
+ * leak). Unset source tenant = global, always resolves. */
104
+ getByNameForTenant(name, tenant) {
105
+ const c = this.connectors.get(name);
106
+ if (!c)
107
+ return undefined;
108
+ const cfg = this.sourceConfigs.get(name);
109
+ if (cfg?.tenant && cfg.tenant !== tenant)
110
+ return undefined;
111
+ return c;
112
+ }
83
113
  async healthCheckAll() {
84
114
  const results = {};
85
115
  for (const [name, connector] of this.connectors) {
@@ -2,15 +2,21 @@ import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { getSupportedTypes, ConnectorRegistry } from "./registry.js";
4
4
  import { DEFAULT_SETTINGS, DEFAULT_HEALTH_THRESHOLDS } from "../config/loader.js";
5
+ import { getPluginLoader } from "./loader.js";
5
6
  function makeConfig(sources = []) {
6
7
  return { sources, settings: DEFAULT_SETTINGS, healthThresholds: DEFAULT_HEALTH_THRESHOLDS };
7
8
  }
8
9
  describe("getSupportedTypes", () => {
9
- it("returns prometheus and loki", () => {
10
+ it("returns the builtins (prometheus, loki, kubernetes) after loader.load()", async () => {
11
+ // The PluginLoader registers builtins inside load(), not the
12
+ // constructor — at server boot index.ts awaits load() before any
13
+ // tool registration code runs. Mirror that here so the test
14
+ // reflects the real wiring rather than a transient empty state.
15
+ await getPluginLoader().load();
10
16
  const types = getSupportedTypes();
11
17
  assert.ok(types.includes("prometheus"));
12
18
  assert.ok(types.includes("loki"));
13
- assert.equal(types.length, 2);
19
+ assert.ok(types.includes("kubernetes"));
14
20
  });
15
21
  });
16
22
  describe("ConnectorRegistry", () => {
@@ -90,4 +96,52 @@ describe("ConnectorRegistry", () => {
90
96
  assert.deepEqual(results, {});
91
97
  });
92
98
  });
99
+ describe("getByTenant / getByNameForTenant", () => {
100
+ it("untagged sources are visible to every tenant (pre-E7 single-tenant default)", async () => {
101
+ await getPluginLoader().load();
102
+ const reg = new ConnectorRegistry();
103
+ await reg.initialize(makeConfig([
104
+ // No tenant on either source — both are "global".
105
+ { name: "prom-global", type: "prometheus", url: "http://p:9090", enabled: true },
106
+ { name: "loki-global", type: "loki", url: "http://l:3100", enabled: true },
107
+ ]));
108
+ const acmeVisible = reg.getByTenant("acme").map((c) => c.name).sort();
109
+ const bigcoVisible = reg.getByTenant("bigco").map((c) => c.name).sort();
110
+ assert.deepEqual(acmeVisible, ["loki-global", "prom-global"]);
111
+ assert.deepEqual(bigcoVisible, ["loki-global", "prom-global"]);
112
+ });
113
+ it("tenant-tagged source is invisible to other tenants", async () => {
114
+ await getPluginLoader().load();
115
+ const reg = new ConnectorRegistry();
116
+ await reg.initialize(makeConfig([
117
+ { name: "shared", type: "prometheus", url: "http://p:9090", enabled: true },
118
+ { name: "acme-only", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
119
+ ]));
120
+ assert.deepEqual(reg.getByTenant("acme").map((c) => c.name).sort(), ["acme-only", "shared"]);
121
+ // bigco sees only the shared source — the acme-only one is hidden.
122
+ assert.deepEqual(reg.getByTenant("bigco").map((c) => c.name).sort(), ["shared"]);
123
+ });
124
+ it("getByNameForTenant returns undefined on cross-tenant probe (no existence leak)", async () => {
125
+ await getPluginLoader().load();
126
+ const reg = new ConnectorRegistry();
127
+ await reg.initialize(makeConfig([
128
+ { name: "acme-loki", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
129
+ ]));
130
+ // Within tenant: resolves.
131
+ assert.ok(reg.getByNameForTenant("acme-loki", "acme"));
132
+ // Cross-tenant: undefined — indistinguishable from "no such source".
133
+ assert.equal(reg.getByNameForTenant("acme-loki", "bigco"), undefined);
134
+ // Unknown name in own tenant: also undefined.
135
+ assert.equal(reg.getByNameForTenant("nope", "acme"), undefined);
136
+ });
137
+ it("a source whose tenant is unset resolves for every tenant via getByNameForTenant", async () => {
138
+ await getPluginLoader().load();
139
+ const reg = new ConnectorRegistry();
140
+ await reg.initialize(makeConfig([
141
+ { name: "global", type: "prometheus", url: "http://p:9090", enabled: true },
142
+ ]));
143
+ assert.ok(reg.getByNameForTenant("global", "acme"));
144
+ assert.ok(reg.getByNameForTenant("global", "bigco"));
145
+ });
146
+ });
93
147
  });
package/dist/context.d.ts CHANGED
@@ -27,6 +27,12 @@ export interface RequestContext {
27
27
  * "default" for anonymous principals + missing-tenant credentials,
28
28
  * preserving the single-namespace behaviour of pre-E7 deployments. */
29
29
  tenant: string;
30
+ /** When set, the /mcp tools/list response is filtered to this
31
+ * allow-list. Resolved from the active credential's bound Product
32
+ * (OMCP_KEY_PRODUCTS) against the catalogue at request entry.
33
+ * Anonymous + Product-less credentials leave this unset and see
34
+ * every registered tool. */
35
+ allowedTools?: string[];
30
36
  /** Correlates all tool calls within one transport request/session. */
31
37
  correlationId: string;
32
38
  }
@@ -36,4 +42,30 @@ export declare function defaultContext(): RequestContext;
36
42
  export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
37
43
  allowBypassRedaction?: boolean;
38
44
  tenant?: string;
45
+ allowedTools?: string[];
39
46
  }): RequestContext;
47
+ /** Context for an authenticated management-plane (browser / OIDC /
48
+ * basic-auth) request. The session-derived tenant flows into tool
49
+ * handlers exactly like the MCP-credential path, so a viewer in
50
+ * tenant Acme reading /api/services through the dashboard sees the
51
+ * same service set as an /mcp client bound to Acme. Anonymous mode
52
+ * (no session) → behaves like defaultContext(). */
53
+ export declare function sessionContext(session: {
54
+ sub?: string;
55
+ name?: string;
56
+ tenant?: string;
57
+ } | undefined): RequestContext;
58
+ /** Decide whether a given tool name is accessible under the active
59
+ * Product binding. Pure helper so the registration site stays
60
+ * declarative and the filtering policy is unit-testable in isolation.
61
+ *
62
+ * Semantics:
63
+ * - undefined allow-list → no Product binding, every tool allowed
64
+ * (anonymous + Product-less credentials — back-compat).
65
+ * - empty allow-list → a Product with no `tools` field. The schema
66
+ * treats this as "all tools allowed", matching the YAML loader's
67
+ * view that an absent / empty list means no restriction.
68
+ * - non-empty → the named tool must appear verbatim.
69
+ * Tool names are compared case-sensitively; the MCP spec is
70
+ * case-sensitive on `name`. */
71
+ export declare function allowsTool(allowedTools: string[] | undefined, toolName: string): boolean;
package/dist/context.js CHANGED
@@ -17,6 +17,41 @@ export function principalContext(principalId, allowedSources, opts = {}) {
17
17
  allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
18
18
  allowBypassRedaction: opts.allowBypassRedaction || undefined,
19
19
  tenant: normaliseTenant(opts.tenant),
20
+ allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
20
21
  correlationId: randomUUID(),
21
22
  };
22
23
  }
24
+ /** Context for an authenticated management-plane (browser / OIDC /
25
+ * basic-auth) request. The session-derived tenant flows into tool
26
+ * handlers exactly like the MCP-credential path, so a viewer in
27
+ * tenant Acme reading /api/services through the dashboard sees the
28
+ * same service set as an /mcp client bound to Acme. Anonymous mode
29
+ * (no session) → behaves like defaultContext(). */
30
+ export function sessionContext(session) {
31
+ if (!session)
32
+ return defaultContext();
33
+ return {
34
+ principalId: session.sub || session.name || "anonymous",
35
+ auth: "apikey",
36
+ tenant: normaliseTenant(session.tenant),
37
+ correlationId: randomUUID(),
38
+ };
39
+ }
40
+ /** Decide whether a given tool name is accessible under the active
41
+ * Product binding. Pure helper so the registration site stays
42
+ * declarative and the filtering policy is unit-testable in isolation.
43
+ *
44
+ * Semantics:
45
+ * - undefined allow-list → no Product binding, every tool allowed
46
+ * (anonymous + Product-less credentials — back-compat).
47
+ * - empty allow-list → a Product with no `tools` field. The schema
48
+ * treats this as "all tools allowed", matching the YAML loader's
49
+ * view that an absent / empty list means no restriction.
50
+ * - non-empty → the named tool must appear verbatim.
51
+ * Tool names are compared case-sensitively; the MCP spec is
52
+ * case-sensitive on `name`. */
53
+ export function allowsTool(allowedTools, toolName) {
54
+ if (!allowedTools || allowedTools.length === 0)
55
+ return true;
56
+ return allowedTools.includes(toolName);
57
+ }
@@ -0,0 +1 @@
1
+ export {};