@thotischner/observability-mcp 3.8.3 → 3.9.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.
@@ -33,10 +33,21 @@
33
33
  * # tools list = no restriction). Unlisted keys see
34
34
  * # every registered tool — back-compat with the
35
35
  * # pre-Products world. See docs/products.md.
36
+ * OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
37
+ * # optional per-key tool allow-list — same shape as
38
+ * # OMCP_KEY_SOURCES. When set, the credential's /mcp
39
+ * # tools/list (and dispatch) is scoped to exactly these
40
+ * # tool names. Composes with a bound Product by
41
+ * # INTERSECTION (most-restrictive wins). Unlisted keys
42
+ * # see every registered tool — back-compat. This is the
43
+ * # per-credential, source-symmetric counterpart to the
44
+ * # Product bundle: scope one API key to a few tools
45
+ * # without authoring a Product. See docs/products.md
46
+ * # ("Per-credential tool allow-list").
36
47
  *
37
- * Rich role-based access control (tools/services/lookback/read-only, the
38
- * full governance object) is intentionally NOT here — this is only the
39
- * authentication + identity + coarse source-scoping primitive.
48
+ * Rich role-based access control (services/lookback/read-only, the full
49
+ * governance object) is intentionally NOT here — this is the authentication
50
+ * + identity + coarse source/tool-scoping primitive.
40
51
  */
41
52
  export interface Credential {
42
53
  name: string;
@@ -58,6 +69,11 @@ export interface Credential {
58
69
  * is filtered to the Product's `tools` allow-list. Resolved against
59
70
  * the credential's tenant so cross-tenant Products don't leak. */
60
71
  productId?: string;
72
+ /** Per-credential tool allow-list (OMCP_KEY_TOOLS). When set, /mcp
73
+ * tools/list and dispatch are scoped to these tool names; composes
74
+ * with a bound Product by intersection (most-restrictive wins).
75
+ * Undefined → no per-credential tool restriction (back-compat). */
76
+ allowedTools?: string[];
61
77
  }
62
78
  /** Parse credentials from env. Returns an empty list when unconfigured. */
63
79
  export declare function loadCredentials(env?: NodeJS.ProcessEnv): Credential[];
@@ -33,10 +33,21 @@
33
33
  * # tools list = no restriction). Unlisted keys see
34
34
  * # every registered tool — back-compat with the
35
35
  * # pre-Products world. See docs/products.md.
36
+ * OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
37
+ * # optional per-key tool allow-list — same shape as
38
+ * # OMCP_KEY_SOURCES. When set, the credential's /mcp
39
+ * # tools/list (and dispatch) is scoped to exactly these
40
+ * # tool names. Composes with a bound Product by
41
+ * # INTERSECTION (most-restrictive wins). Unlisted keys
42
+ * # see every registered tool — back-compat. This is the
43
+ * # per-credential, source-symmetric counterpart to the
44
+ * # Product bundle: scope one API key to a few tools
45
+ * # without authoring a Product. See docs/products.md
46
+ * # ("Per-credential tool allow-list").
36
47
  *
37
- * Rich role-based access control (tools/services/lookback/read-only, the
38
- * full governance object) is intentionally NOT here — this is only the
39
- * authentication + identity + coarse source-scoping primitive.
48
+ * Rich role-based access control (services/lookback/read-only, the full
49
+ * governance object) is intentionally NOT here — this is the authentication
50
+ * + identity + coarse source/tool-scoping primitive.
40
51
  */
41
52
  import { parseKeyTenants } from "../tenancy/context.js";
42
53
  function parseKeySources(raw) {
@@ -84,6 +95,8 @@ export function loadCredentials(env = process.env) {
84
95
  const rawQueryNames = parseBypassSet(env.OMCP_KEY_RAW_QUERY);
85
96
  const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
86
97
  const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
98
+ // OMCP_KEY_TOOLS shares the OMCP_KEY_SOURCES grammar (`name=a|b;name2=c`).
99
+ const keyTools = parseKeySources(env.OMCP_KEY_TOOLS);
87
100
  const creds = [];
88
101
  for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
89
102
  const idx = part.indexOf(":");
@@ -99,6 +112,7 @@ export function loadCredentials(env = process.env) {
99
112
  allowRawQuery: rawQueryNames.has(name) || undefined,
100
113
  tenant: keyTenants.get(name) || undefined,
101
114
  productId: keyProducts.get(name) || undefined,
115
+ allowedTools: keyTools.get(name),
102
116
  });
103
117
  }
104
118
  return creds;
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
11
11
  it("parses name:token and bare token", () => {
12
12
  const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
13
13
  assert.equal(creds.length, 2);
14
- assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined });
14
+ assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined, allowedTools: undefined });
15
15
  assert.equal(creds[1].name, "key");
16
16
  assert.equal(creds[1].token, "tok_bare");
17
17
  });
@@ -23,6 +23,16 @@ describe("single-tenant auth primitive", () => {
23
23
  assert.deepEqual(creds[0].allowedSources, ["prom-prod", "loki-prod"]);
24
24
  assert.deepEqual(creds[1].allowedSources, ["prom-staging"]);
25
25
  });
26
+ it("parses per-key tool allow-list (OMCP_KEY_TOOLS)", () => {
27
+ const creds = loadCredentials({
28
+ OMCP_API_KEYS: "agent:tok1,ci:tok2,full:tok3",
29
+ OMCP_KEY_TOOLS: "agent=query_logs|get_service_health; ci=list_services",
30
+ });
31
+ assert.deepEqual(creds.find((c) => c.name === "agent")?.allowedTools, ["query_logs", "get_service_health"]);
32
+ assert.deepEqual(creds.find((c) => c.name === "ci")?.allowedTools, ["list_services"]);
33
+ // Unlisted key → undefined (no restriction, back-compat), not [].
34
+ assert.equal(creds.find((c) => c.name === "full")?.allowedTools, undefined);
35
+ });
26
36
  it("parses OMCP_KEY_BYPASS_REDACTION → flags only the listed names", () => {
27
37
  const creds = loadCredentials({
28
38
  OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
@@ -27,6 +27,7 @@ export declare class PluginLoader {
27
27
  private verify;
28
28
  private trustRootPath?;
29
29
  private trustRoot?;
30
+ private requireSignature;
30
31
  /** Optional HookRegistry — when set, the loader auto-registers
31
32
  * every entry in `manifest.hooks[]` after the plugin loads, and
32
33
  * unregisters them when a same-name plugin replaces it. Hooks
@@ -39,6 +40,7 @@ export declare class PluginLoader {
39
40
  verify?: boolean;
40
41
  trustRoot?: string;
41
42
  hookRegistry?: HookRegistry;
43
+ requireSignature?: boolean;
42
44
  });
43
45
  load(): Promise<void>;
44
46
  list(): LoadedConnector[];
@@ -49,6 +51,10 @@ export declare class PluginLoader {
49
51
  create(name: string): ObservabilityConnector | undefined;
50
52
  private loadBuiltins;
51
53
  private loadFilesystem;
54
+ /** A filesystem plugin failed the verification gate. In strict mode
55
+ * (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
56
+ * warns and the caller skips the plugin (fail-closed but non-fatal). */
57
+ private gateFailure;
52
58
  private loadFilesystemPlugin;
53
59
  private register;
54
60
  }
@@ -31,6 +31,15 @@ export class PluginLoader {
31
31
  verify;
32
32
  trustRootPath;
33
33
  trustRoot;
34
+ // Strict mode (PLUGIN_REQUIRE_SIGNATURE=true). Default OFF. When OFF, a
35
+ // filesystem plugin that fails the verification gate is silently SKIPPED
36
+ // (warn + continue) — the server still boots, just without that connector.
37
+ // When ON, a present-but-unverifiable plugin (missing manifest / missing
38
+ // or invalid signature / integrity mismatch), or a missing/unloadable trust
39
+ // root, is a HARD error that aborts load() — so an operator who demands
40
+ // signed connectors fails to start rather than silently running a reduced
41
+ // set. Implies verify (you cannot require signatures without verifying).
42
+ requireSignature;
34
43
  /** Optional HookRegistry — when set, the loader auto-registers
35
44
  * every entry in `manifest.hooks[]` after the plugin loads, and
36
45
  * unregisters them when a same-name plugin replaces it. Hooks
@@ -50,11 +59,24 @@ export class PluginLoader {
50
59
  this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
51
60
  this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
52
61
  this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
62
+ this.requireSignature =
63
+ opts.requireSignature ?? /^(1|true|yes|on)$/i.test(process.env.PLUGIN_REQUIRE_SIGNATURE ?? "");
64
+ // Requiring signatures implies verifying them — an operator who sets
65
+ // PLUGIN_REQUIRE_SIGNATURE=true but left VERIFY_PLUGINS=false meant the
66
+ // stricter posture; honour it rather than silently no-op.
67
+ if (this.requireSignature)
68
+ this.verify = true;
53
69
  }
54
70
  async load() {
55
71
  this.loadBuiltins();
56
72
  if (this.verify) {
57
73
  if (!this.trustRootPath) {
74
+ // In strict mode this is a misconfiguration — the operator demanded
75
+ // signed connectors but gave no way to verify them. Fail hard rather
76
+ // than silently degrade to builtins-only.
77
+ if (this.requireSignature) {
78
+ throw new PluginVerificationError("PLUGIN_REQUIRE_SIGNATURE is on but PLUGIN_TRUST_ROOT is unset — cannot enforce signatures. Set a trust root or unset PLUGIN_REQUIRE_SIGNATURE.");
79
+ }
58
80
  console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
59
81
  return;
60
82
  }
@@ -63,6 +85,9 @@ export class PluginLoader {
63
85
  console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
64
86
  }
65
87
  catch (err) {
88
+ if (this.requireSignature) {
89
+ throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE is on but the trust root failed to load (${String(err)}) — cannot enforce signatures.`);
90
+ }
66
91
  console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
67
92
  return;
68
93
  }
@@ -161,10 +186,23 @@ export class PluginLoader {
161
186
  await this.loadFilesystemPlugin(pluginRoot);
162
187
  }
163
188
  catch (err) {
189
+ // In strict mode a verification failure must abort load() rather than
190
+ // be swallowed as a per-plugin warning — propagate it.
191
+ if (this.requireSignature && err instanceof PluginVerificationError)
192
+ throw err;
164
193
  console.warn("Failed to load plugin %s: %s", sanitizeForLog(entry), sanitizeForLog(String(err)));
165
194
  }
166
195
  }
167
196
  }
197
+ /** A filesystem plugin failed the verification gate. In strict mode
198
+ * (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
199
+ * warns and the caller skips the plugin (fail-closed but non-fatal). */
200
+ gateFailure(name, reason) {
201
+ if (this.requireSignature) {
202
+ throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE: plugin ${name} ${reason} — refusing to start.`);
203
+ }
204
+ console.warn("VERIFY_PLUGINS: plugin %s %s — skipping (fail-closed)", sanitizeForLog(name), sanitizeForLog(reason));
205
+ }
168
206
  async loadFilesystemPlugin(pluginRoot) {
169
207
  const pkgPath = join(pluginRoot, "package.json");
170
208
  if (!existsSync(pkgPath))
@@ -180,18 +218,29 @@ export class PluginLoader {
180
218
  manifestPath = resolve(pluginRoot, marker.manifest);
181
219
  if (existsSync(manifestPath)) {
182
220
  manifestBytes = readFileSync(manifestPath);
183
- const raw = JSON.parse(manifestBytes.toString("utf8"));
221
+ // A present-but-malformed manifest (corrupt JSON / schema-invalid /
222
+ // name mismatch) is "present but cannot be verified" — under strict
223
+ // mode gateFailure() aborts startup, so the connector can't silently
224
+ // go missing; otherwise it warns + skips as before.
225
+ let raw;
226
+ try {
227
+ raw = JSON.parse(manifestBytes.toString("utf8"));
228
+ }
229
+ catch (err) {
230
+ this.gateFailure(marker.name, `has unparseable manifest.json (${String(err)})`);
231
+ return;
232
+ }
184
233
  const parsed = manifestSchema.safeParse(raw);
185
234
  if (!parsed.success) {
186
235
  const issues = parsed.error.issues
187
236
  .map((i) => `${i.path.join(".")}: ${i.message}`)
188
237
  .join("; ");
189
- console.warn("Plugin %s has invalid manifest.json — %s; skipping", sanitizeForLog(marker.name), sanitizeForLog(issues));
238
+ this.gateFailure(marker.name, `has invalid manifest.json — ${issues}`);
190
239
  return;
191
240
  }
192
241
  manifest = parsed.data;
193
242
  if (manifest.name !== marker.name) {
194
- console.warn("Plugin %s package.json marker name does not match manifest.json (%s); skipping", sanitizeForLog(marker.name), sanitizeForLog(manifest.name));
243
+ this.gateFailure(marker.name, `package.json marker name does not match manifest.json (${manifest.name})`);
195
244
  return;
196
245
  }
197
246
  }
@@ -208,12 +257,12 @@ export class PluginLoader {
208
257
  // against the trust root. Everything is local — airgapped-safe.
209
258
  if (this.verify) {
210
259
  if (!manifest || !manifestPath || !manifestBytes) {
211
- console.warn("VERIFY_PLUGINS: plugin %s has no manifest.json — skipping (fail-closed)", sanitizeForLog(marker.name));
260
+ this.gateFailure(marker.name, "has no manifest.json");
212
261
  return;
213
262
  }
214
263
  const sigPath = manifestPath + ".sig";
215
264
  if (!existsSync(sigPath)) {
216
- console.warn("VERIFY_PLUGINS: plugin %s missing manifest signature %s — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(marker.manifest + ".sig"));
265
+ this.gateFailure(marker.name, `missing manifest signature ${marker.manifest + ".sig"}`);
217
266
  return;
218
267
  }
219
268
  try {
@@ -222,7 +271,7 @@ export class PluginLoader {
222
271
  }
223
272
  catch (err) {
224
273
  const detail = err instanceof PluginVerificationError ? err.message : String(err);
225
- console.warn("VERIFY_PLUGINS: plugin %s failed verification (%s) — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(detail));
274
+ this.gateFailure(marker.name, `failed verification (${detail})`);
226
275
  return;
227
276
  }
228
277
  console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
@@ -1,12 +1,49 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { generateKeyPairSync, sign as cryptoSign } from "node:crypto";
6
7
  import { PluginLoader } from "./loader.js";
8
+ import { PluginVerificationError, sha256Integrity } from "./verify.js";
7
9
  function tmp() {
8
10
  return mkdtempSync(join(tmpdir(), "loader-default-"));
9
11
  }
12
+ const ENTRY_SRC = "export default () => ({});\n";
13
+ /** Write a public-key PEM trust root + return its path and the signing key. */
14
+ function makeTrustRoot() {
15
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
16
+ const dir = mkdtempSync(join(tmpdir(), "loader-trust-"));
17
+ const p = join(dir, "trust.pem");
18
+ writeFileSync(p, publicKey.export({ type: "spki", format: "pem" }));
19
+ return { path: p, privateKey };
20
+ }
21
+ /** Build a filesystem connector plugin dir under `pluginsDir`.
22
+ * opts.manifest=false → no manifest.json; opts.sign omitted → no .sig;
23
+ * opts.sign=key → a valid detached signature over the manifest bytes. */
24
+ function makePlugin(pluginsDir, name, opts = {}) {
25
+ const root = join(pluginsDir, name);
26
+ mkdirSync(root, { recursive: true });
27
+ writeFileSync(join(root, "index.js"), ENTRY_SRC);
28
+ writeFileSync(join(root, "package.json"), JSON.stringify({ main: "index.js", observabilityMcp: { kind: "connector", name, manifest: "manifest.json" } }));
29
+ if (opts.manifest === false)
30
+ return;
31
+ const manifestPath = join(root, "manifest.json");
32
+ const manifestBytes = opts.rawManifest !== undefined
33
+ ? Buffer.from(opts.rawManifest)
34
+ : Buffer.from(JSON.stringify({
35
+ schemaVersion: 1,
36
+ name,
37
+ displayName: name,
38
+ version: "1.0.0",
39
+ description: `${name} test connector`,
40
+ signalTypes: ["metrics"],
41
+ integrity: opts.integrity ?? sha256Integrity(Buffer.from(ENTRY_SRC)),
42
+ }));
43
+ writeFileSync(manifestPath, manifestBytes);
44
+ if (opts.sign)
45
+ writeFileSync(manifestPath + ".sig", cryptoSign(null, manifestBytes, opts.sign));
46
+ }
10
47
  function withEnv(overrides, fn) {
11
48
  const saved = {};
12
49
  for (const k of Object.keys(overrides)) {
@@ -87,3 +124,87 @@ test("PluginLoader.load(): builtins carry manifest metadata (description shows i
87
124
  assert.equal(c.manifest.name, name);
88
125
  }
89
126
  });
127
+ // --- PLUGIN_REQUIRE_SIGNATURE (strict load-time enforcement) ---
128
+ test("PluginLoader: PLUGIN_REQUIRE_SIGNATURE=true sets requireSignature and forces verify on", () => {
129
+ for (const v of ["true", "1", "yes", "On"]) {
130
+ // Even with VERIFY_PLUGINS=false, requiring signatures implies verifying.
131
+ withEnv({ PLUGIN_REQUIRE_SIGNATURE: v, VERIFY_PLUGINS: "false" }, () => {
132
+ const loader = new PluginLoader({ pluginsDir: tmp() });
133
+ assert.equal(loader["requireSignature"], true, `value ${v} should enable strict mode`);
134
+ assert.equal(loader["verify"], true, "requireSignature implies verify");
135
+ });
136
+ }
137
+ withEnv({ PLUGIN_REQUIRE_SIGNATURE: undefined }, () => {
138
+ assert.equal(new PluginLoader({ pluginsDir: tmp() })["requireSignature"], false, "default OFF");
139
+ });
140
+ });
141
+ test("strict mode: a plugin with no manifest ABORTS load() (hard fail, not skip)", async () => {
142
+ const dir = tmp();
143
+ makePlugin(dir, "unsigned-conn", { manifest: false });
144
+ const trust = makeTrustRoot();
145
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
146
+ await assert.rejects(() => loader.load(), PluginVerificationError);
147
+ });
148
+ test("strict mode: a plugin with manifest but missing .sig ABORTS load()", async () => {
149
+ const dir = tmp();
150
+ makePlugin(dir, "nosig-conn", { /* manifest yes, sign no */});
151
+ const trust = makeTrustRoot();
152
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
153
+ await assert.rejects(() => loader.load(), PluginVerificationError);
154
+ });
155
+ test("strict mode: missing trust root ABORTS load() (misconfiguration)", async () => {
156
+ const loader = new PluginLoader({ pluginsDir: tmp(), verify: true, trustRoot: undefined, requireSignature: true });
157
+ await assert.rejects(() => loader.load(), /PLUGIN_TRUST_ROOT is unset/);
158
+ });
159
+ test("NON-strict (default): the same unverifiable plugin is skipped, builtins still load", async () => {
160
+ const dir = tmp();
161
+ makePlugin(dir, "unsigned-conn", { manifest: false });
162
+ const trust = makeTrustRoot();
163
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* requireSignature default false */ });
164
+ await loader.load(); // must NOT throw
165
+ const names = loader.supportedTypes();
166
+ assert.ok(names.includes("prometheus"), "builtins still load");
167
+ assert.ok(!names.includes("unsigned-conn"), "unverifiable plugin skipped, not registered");
168
+ });
169
+ test("strict mode: a correctly-signed plugin loads without error", async () => {
170
+ const dir = tmp();
171
+ const trust = makeTrustRoot();
172
+ makePlugin(dir, "good-conn", { sign: trust.privateKey });
173
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
174
+ await loader.load(); // valid signature + integrity → no throw
175
+ assert.ok(loader.supportedTypes().includes("good-conn"), "signed plugin registered under strict mode");
176
+ });
177
+ // A present-but-malformed manifest is "present but cannot be verified" — strict
178
+ // mode must hard-fail, not silently drop the connector.
179
+ test("strict mode: unparseable manifest.json ABORTS load()", async () => {
180
+ const dir = tmp();
181
+ makePlugin(dir, "corrupt-conn", { rawManifest: "{ this is not valid json" });
182
+ const trust = makeTrustRoot();
183
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
184
+ await assert.rejects(() => loader.load(), PluginVerificationError);
185
+ });
186
+ test("strict mode: schema-invalid manifest.json ABORTS load()", async () => {
187
+ const dir = tmp();
188
+ // Missing required fields (displayName/version/signalTypes/...).
189
+ makePlugin(dir, "badschema-conn", { rawManifest: JSON.stringify({ schemaVersion: 1, name: "badschema-conn" }) });
190
+ const trust = makeTrustRoot();
191
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
192
+ await assert.rejects(() => loader.load(), PluginVerificationError);
193
+ });
194
+ test("strict mode: integrity mismatch ABORTS load() (signed manifest, wrong digest)", async () => {
195
+ const dir = tmp();
196
+ const trust = makeTrustRoot();
197
+ // Validly signed manifest, but its integrity does not match index.js.
198
+ makePlugin(dir, "tampered-conn", { sign: trust.privateKey, integrity: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" });
199
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
200
+ await assert.rejects(() => loader.load(), PluginVerificationError);
201
+ });
202
+ test("NON-strict: a malformed manifest is still skipped (not fatal), builtins load", async () => {
203
+ const dir = tmp();
204
+ makePlugin(dir, "corrupt-conn", { rawManifest: "{ nope" });
205
+ const trust = makeTrustRoot();
206
+ const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* non-strict */ });
207
+ await loader.load(); // must NOT throw
208
+ assert.ok(loader.supportedTypes().includes("prometheus"), "builtins still load");
209
+ assert.ok(!loader.supportedTypes().includes("corrupt-conn"), "malformed plugin skipped");
210
+ });
package/dist/context.d.ts CHANGED
@@ -39,6 +39,12 @@ export interface RequestContext {
39
39
  * Anonymous + Product-less credentials leave this unset and see
40
40
  * every registered tool. */
41
41
  allowedTools?: string[];
42
+ /** Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
43
+ * the Product `allowedTools`. The registration gate requires a tool to
44
+ * pass BOTH (each via allowsTool, undefined = no restriction), so the two
45
+ * compose by intersection without overloading the empty-list semantics.
46
+ * Unset for anonymous + unscoped credentials. */
47
+ credentialTools?: string[];
42
48
  /** Correlates all tool calls within one transport request/session. */
43
49
  correlationId: string;
44
50
  }
@@ -59,6 +65,7 @@ export declare function principalContext(principalId: string, allowedSources?: s
59
65
  allowRawQuery?: boolean;
60
66
  tenant?: string;
61
67
  allowedTools?: string[];
68
+ credentialTools?: string[];
62
69
  }): RequestContext;
63
70
  /** Context for an authenticated management-plane (browser / OIDC /
64
71
  * basic-auth) request. The session-derived tenant flows into tool
@@ -85,3 +92,14 @@ export declare function sessionContext(session: {
85
92
  * Tool names are compared case-sensitively; the MCP spec is
86
93
  * case-sensitive on `name`. */
87
94
  export declare function allowsTool(allowedTools: string[] | undefined, toolName: string): boolean;
95
+ /** Combine two tool allow-lists into the effective (most-restrictive) one.
96
+ * Both lists NARROW access, so the result is their intersection:
97
+ * - either side undefined → the other side wins (an absent list means
98
+ * "no restriction", so it can't tighten the other).
99
+ * - both set → only tools present in BOTH survive (so a per-credential
100
+ * OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
101
+ * without one silently widening the other; an empty intersection
102
+ * means the credential can call nothing through that binding).
103
+ * Used to fold the per-credential allow-list together with the Product
104
+ * allow-list at request entry. */
105
+ export declare function intersectAllowed(a: string[] | undefined, b: string[] | undefined): string[] | undefined;
package/dist/context.js CHANGED
@@ -27,6 +27,7 @@ export function principalContext(principalId, allowedSources, opts = {}) {
27
27
  allowRawQuery: opts.allowRawQuery || undefined,
28
28
  tenant: normaliseTenant(opts.tenant),
29
29
  allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
30
+ credentialTools: opts.credentialTools && opts.credentialTools.length > 0 ? opts.credentialTools : undefined,
30
31
  correlationId: randomUUID(),
31
32
  };
32
33
  }
@@ -64,3 +65,21 @@ export function allowsTool(allowedTools, toolName) {
64
65
  return true;
65
66
  return allowedTools.includes(toolName);
66
67
  }
68
+ /** Combine two tool allow-lists into the effective (most-restrictive) one.
69
+ * Both lists NARROW access, so the result is their intersection:
70
+ * - either side undefined → the other side wins (an absent list means
71
+ * "no restriction", so it can't tighten the other).
72
+ * - both set → only tools present in BOTH survive (so a per-credential
73
+ * OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
74
+ * without one silently widening the other; an empty intersection
75
+ * means the credential can call nothing through that binding).
76
+ * Used to fold the per-credential allow-list together with the Product
77
+ * allow-list at request entry. */
78
+ export function intersectAllowed(a, b) {
79
+ if (!a)
80
+ return b;
81
+ if (!b)
82
+ return a;
83
+ const bSet = new Set(b);
84
+ return a.filter((t) => bSet.has(t));
85
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { allowsTool, defaultContext, principalContext } from "./context.js";
3
+ import { allowsTool, intersectAllowed, defaultContext, principalContext } from "./context.js";
4
4
  test("allowsTool — undefined allow-list = no Product binding = every tool allowed", () => {
5
5
  assert.equal(allowsTool(undefined, "list_sources"), true);
6
6
  assert.equal(allowsTool(undefined, "query_logs"), true);
@@ -15,6 +15,44 @@ test("allowsTool — non-empty allow-list gates by exact match", () => {
15
15
  assert.equal(allowsTool(allow, "query_logs"), false);
16
16
  assert.equal(allowsTool(allow, "get_topology"), false);
17
17
  });
18
+ // intersectAllowed folds a per-credential list (OMCP_KEY_TOOLS) with a Product
19
+ // list — most-restrictive wins.
20
+ test("intersectAllowed — either side undefined returns the other (no widening)", () => {
21
+ assert.equal(intersectAllowed(undefined, undefined), undefined);
22
+ assert.deepEqual(intersectAllowed(["a", "b"], undefined), ["a", "b"]);
23
+ assert.deepEqual(intersectAllowed(undefined, ["a", "b"]), ["a", "b"]);
24
+ });
25
+ test("intersectAllowed — both set → intersection only", () => {
26
+ assert.deepEqual(intersectAllowed(["query_logs", "list_services"], ["query_logs", "get_topology"]), ["query_logs"]);
27
+ // Disjoint → empty list: the credential can call nothing through that binding.
28
+ assert.deepEqual(intersectAllowed(["query_logs"], ["get_topology"]), []);
29
+ // Order follows the first (credential) list.
30
+ assert.deepEqual(intersectAllowed(["b", "a"], ["a", "b"]), ["b", "a"]);
31
+ });
32
+ // The registration gate ANDs two independent allowsTool axes (Product +
33
+ // per-credential OMCP_KEY_TOOLS). This is what makes disjoint lists deny
34
+ // everything WITHOUT routing through an overloaded empty intersection — an
35
+ // empty `[]` would be read by allowsTool as "allow all", which is why the two
36
+ // axes are kept separate rather than pre-intersected into one list.
37
+ function passesGate(productTools, credTools, name) {
38
+ return allowsTool(productTools, name) && allowsTool(credTools, name);
39
+ }
40
+ test("two-axis gate — disjoint Product and credential lists deny every tool", () => {
41
+ // Product allows get_topology; credential allows only query_logs → nothing passes.
42
+ assert.equal(passesGate(["get_topology"], ["query_logs"], "query_logs"), false);
43
+ assert.equal(passesGate(["get_topology"], ["query_logs"], "get_topology"), false);
44
+ });
45
+ test("two-axis gate — credential list narrows within an unrestricted Product axis", () => {
46
+ assert.equal(passesGate(undefined, ["query_logs"], "query_logs"), true);
47
+ assert.equal(passesGate(undefined, ["query_logs"], "get_topology"), false);
48
+ });
49
+ test("two-axis gate — overlapping lists allow only the overlap", () => {
50
+ const product = ["query_logs", "get_topology", "list_services"];
51
+ const cred = ["query_logs", "list_services"];
52
+ assert.equal(passesGate(product, cred, "query_logs"), true);
53
+ assert.equal(passesGate(product, cred, "list_services"), true);
54
+ assert.equal(passesGate(product, cred, "get_topology"), false); // in Product, not in credential
55
+ });
18
56
  test("allowsTool — case-sensitive (matches MCP spec)", () => {
19
57
  const allow = ["list_sources"];
20
58
  assert.equal(allowsTool(allow, "List_Sources"), false);
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { z } from "zod";
9
9
  import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
10
10
  import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
11
11
  import { isTopologyProvider } from "./connectors/interface.js";
12
- import { defaultContext, principalContext, sessionContext, allowsTool } from "./context.js";
12
+ import { defaultContext, principalContext, sessionContext, allowsTool, intersectAllowed } from "./context.js";
13
13
  import { parseKeyTenants, isMultiTenantConfigured } from "./tenancy/context.js";
14
14
  import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, inspectEnforceEntitled, featureEntitled, entitledFeatures, } from "./enterprise-gate.js";
15
15
  import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
@@ -382,8 +382,13 @@ async function main() {
382
382
  // reaches the caller. When no hooks are registered (the default in
383
383
  // the OSS demo) the wrapper is a thin pass-through.
384
384
  const registerTool = ((name, ...rest) => {
385
- if (!allowsTool(ctx.allowedTools, name))
385
+ // Two independent allow-list axes must BOTH pass: the Product binding
386
+ // (ctx.allowedTools, OMCP_KEY_PRODUCTS) and the per-credential list
387
+ // (ctx.credentialTools, OMCP_KEY_TOOLS). Either undefined = no restriction
388
+ // on that axis; disjoint non-empty lists therefore deny every tool.
389
+ if (!allowsTool(ctx.allowedTools, name) || !allowsTool(ctx.credentialTools, name)) {
386
390
  return undefined;
391
+ }
387
392
  if (rest.length > 0 && typeof rest[rest.length - 1] === "function") {
388
393
  const originalHandler = rest[rest.length - 1];
389
394
  const wrappedHandler = wrapToolHandler(hookRegistry, { principal: ctx.principalId, tenant: ctx.tenant || "default", target: name }, originalHandler);
@@ -1849,6 +1854,7 @@ async function main() {
1849
1854
  productId: c.productId,
1850
1855
  bypassRedaction: !!c.bypassRedaction,
1851
1856
  allowedSources: c.allowedSources,
1857
+ allowedTools: c.allowedTools,
1852
1858
  });
1853
1859
  }
1854
1860
  // OIDC groups → role mappings.
@@ -3670,6 +3676,11 @@ async function main() {
3670
3676
  allowRawQuery: cred.allowRawQuery,
3671
3677
  tenant: cred.tenant,
3672
3678
  allowedTools,
3679
+ // Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
3680
+ // the Product one. The registration gate requires a tool to pass BOTH,
3681
+ // so disjoint lists deny everything without overloading the empty-list
3682
+ // "allow all" semantics allowsTool uses for the Product axis.
3683
+ credentialTools: cred.allowedTools,
3673
3684
  });
3674
3685
  }
3675
3686
  app.post("/mcp", async (req, res) => {
@@ -3741,14 +3752,6 @@ async function main() {
3741
3752
  // registerTool gate, so the surface a /mcp/v/<slug> client sees is
3742
3753
  // strictly product.tools (intersected with any pre-existing
3743
3754
  // allowedTools the credential already carries).
3744
- function intersectAllowed(a, b) {
3745
- if (!a)
3746
- return b;
3747
- if (!b)
3748
- return a;
3749
- const bSet = new Set(b);
3750
- return a.filter((t) => bSet.has(t));
3751
- }
3752
3755
  async function resolveVirtualProduct(req, res, baseCtx) {
3753
3756
  const slug = req.params.slug;
3754
3757
  if (!slug || typeof slug !== "string") {
@@ -3911,6 +3914,7 @@ async function main() {
3911
3914
  allowRawQuery: cred.allowRawQuery,
3912
3915
  tenant: cred.tenant,
3913
3916
  allowedTools,
3917
+ credentialTools: cred.allowedTools,
3914
3918
  }),
3915
3919
  selectedSubprotocol,
3916
3920
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.8.3",
3
+ "version": "3.9.0",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",