@thotischner/observability-mcp 3.8.2 → 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);
@@ -6,7 +6,27 @@ export type FetchLike = (url: string, init?: {
6
6
  ok: boolean;
7
7
  status: number;
8
8
  json: () => Promise<unknown>;
9
+ /** Optional — used to honor `Retry-After` on a throttle response. */
10
+ headers?: {
11
+ get(name: string): string | null;
12
+ };
9
13
  }>;
14
+ /** Why a lookup failed in a way that is NOT a stable property of the address —
15
+ * retrying later (or in a smaller batch) may succeed. Issue #523. */
16
+ export type RdapTransientReason = "rate_limited" | "timeout" | "upstream_error" | "network_error";
17
+ /** Outcome of a single RDAP lookup. `not_found` is a genuine negative (the
18
+ * address is not in any registry / carries no enrichment) and is safe to
19
+ * cache; `transient` is an upstream failure (throttle, timeout, 5xx) that must
20
+ * NOT be conflated with a negative and is never cached. */
21
+ export type RdapOutcome = {
22
+ status: "ok";
23
+ value: IpEnrichment;
24
+ } | {
25
+ status: "not_found";
26
+ } | {
27
+ status: "transient";
28
+ reason: RdapTransientReason;
29
+ };
10
30
  export interface RdapResolverOptions {
11
31
  /** Bootstrap base; rdap.org redirects to the authoritative RIR. */
12
32
  baseUrl?: string;
@@ -18,6 +38,14 @@ export interface RdapResolverOptions {
18
38
  fetch?: FetchLike;
19
39
  /** Max cache entries (LRU-ish trim). */
20
40
  maxCache?: number;
41
+ /** Retries on a TRANSIENT failure (throttle/timeout/5xx). Default 2. A true
42
+ * negative (404 / no-data) is never retried. */
43
+ maxRetries?: number;
44
+ /** Base backoff in ms; attempt N waits min(base * 2^N, 5000), unless the
45
+ * throttle response carries a usable `Retry-After`. Default 250. */
46
+ backoffMs?: number;
47
+ /** Injected sleep (tests pass a no-op to stay fast). Defaults to setTimeout. */
48
+ sleep?: (ms: number) => Promise<void>;
21
49
  }
22
50
  /** Parse an RDAP IP-network response into our enrichment shape. country +
23
51
  * org/name only; RDAP carries no city or hosting flag. */
@@ -29,12 +57,23 @@ export declare class RdapResolver {
29
57
  private readonly timeoutMs;
30
58
  private readonly fetch;
31
59
  private readonly maxCache;
60
+ private readonly maxRetries;
61
+ private readonly backoffMs;
62
+ private readonly sleep;
32
63
  private cache;
33
64
  /** Monotonic clock injected for tests; defaults to Date.now via a getter. */
34
65
  now: () => number;
35
66
  constructor(opts?: RdapResolverOptions);
36
- /** Look up one IP via RDAP. Returns null on miss/error (never throws
37
- * a flaky RIR must not fail the batch). Cached by IP with a TTL. */
67
+ /** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
68
+ * miss OR a transient failure (never throws). Back-compat shim callers
69
+ * that need to tell a true negative from a throttle should use {@link resolve}. */
38
70
  lookup(ip: string): Promise<IpEnrichment | null>;
71
+ /** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
72
+ * cached) from a transient upstream failure (`transient`, never cached so a
73
+ * later retry can succeed). Bounded retry with backoff on transient. Never
74
+ * throws — a flaky RIR must not fail the batch (issue #523). */
75
+ resolve(ip: string): Promise<RdapOutcome>;
76
+ /** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
77
+ private attempt;
39
78
  private put;
40
79
  }
@@ -71,6 +71,9 @@ export class RdapResolver {
71
71
  timeoutMs;
72
72
  fetch;
73
73
  maxCache;
74
+ maxRetries;
75
+ backoffMs;
76
+ sleep;
74
77
  cache = new Map();
75
78
  /** Monotonic clock injected for tests; defaults to Date.now via a getter. */
76
79
  now;
@@ -81,34 +84,74 @@ export class RdapResolver {
81
84
  this.timeoutMs = opts.timeoutMs ?? 4000;
82
85
  this.fetch = opts.fetch ?? globalThis.fetch;
83
86
  this.maxCache = opts.maxCache ?? 10_000;
87
+ this.maxRetries = opts.maxRetries ?? 2;
88
+ this.backoffMs = opts.backoffMs ?? 250;
89
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
84
90
  this.now = () => Date.now();
85
91
  }
86
- /** Look up one IP via RDAP. Returns null on miss/error (never throws
87
- * a flaky RIR must not fail the batch). Cached by IP with a TTL. */
92
+ /** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
93
+ * miss OR a transient failure (never throws). Back-compat shim callers
94
+ * that need to tell a true negative from a throttle should use {@link resolve}. */
88
95
  async lookup(ip) {
96
+ const o = await this.resolve(ip);
97
+ return o.status === "ok" ? o.value : null;
98
+ }
99
+ /** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
100
+ * cached) from a transient upstream failure (`transient`, never cached so a
101
+ * later retry can succeed). Bounded retry with backoff on transient. Never
102
+ * throws — a flaky RIR must not fail the batch (issue #523). */
103
+ async resolve(ip) {
89
104
  if (ipv4ToInt(ip) === null && ipv6ToBigInt(ip) === null)
90
- return null;
105
+ return { status: "not_found" };
91
106
  const cached = this.cache.get(ip);
92
- if (cached && cached.expiresAt > this.now())
93
- return cached.value;
94
- let value = null;
107
+ if (cached && cached.expiresAt > this.now()) {
108
+ return cached.value ? { status: "ok", value: cached.value } : { status: "not_found" };
109
+ }
110
+ for (let attempt = 0;; attempt++) {
111
+ const { outcome, retryAfterMs } = await this.attempt(ip);
112
+ if (outcome.status === "ok") {
113
+ this.put(ip, { value: outcome.value, expiresAt: this.now() + this.ttlMs });
114
+ return outcome;
115
+ }
116
+ if (outcome.status === "not_found") {
117
+ this.put(ip, { value: null, expiresAt: this.now() + this.negTtlMs });
118
+ return outcome;
119
+ }
120
+ // transient — retry with backoff, but never cache it as a negative.
121
+ if (attempt >= this.maxRetries)
122
+ return outcome;
123
+ const backoff = retryAfterMs ?? Math.min(this.backoffMs * 2 ** attempt, 5000);
124
+ await this.sleep(backoff);
125
+ }
126
+ }
127
+ /** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
128
+ async attempt(ip) {
129
+ const ac = new AbortController();
130
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
95
131
  try {
96
- const ac = new AbortController();
97
- const timer = setTimeout(() => ac.abort(), this.timeoutMs);
98
- try {
99
- const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
100
- if (res.ok)
101
- value = parseRdapResponse(await res.json());
132
+ const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
133
+ if (res.ok) {
134
+ const parsed = parseRdapResponse(await res.json());
135
+ // A 2xx with no country/org is a genuine "no enrichment for this IP".
136
+ return { outcome: parsed ? { status: "ok", value: parsed } : { status: "not_found" } };
102
137
  }
103
- finally {
104
- clearTimeout(timer);
138
+ // 429/403 are the throttle responses RIRs use; 5xx is upstream trouble —
139
+ // both are transient. 404 (and other malformed-query 4xx) is a genuine
140
+ // negative for this address.
141
+ if (res.status === 429 || res.status === 403) {
142
+ return { outcome: { status: "transient", reason: "rate_limited" }, retryAfterMs: retryAfter(res) };
105
143
  }
144
+ if (res.status >= 500)
145
+ return { outcome: { status: "transient", reason: "upstream_error" } };
146
+ return { outcome: { status: "not_found" } };
106
147
  }
107
148
  catch {
108
- value = null; // network/timeout/parse treat as a miss
149
+ // AbortController fired our own timeout; otherwise a network error.
150
+ return { outcome: { status: "transient", reason: ac.signal.aborted ? "timeout" : "network_error" } };
151
+ }
152
+ finally {
153
+ clearTimeout(timer);
109
154
  }
110
- this.put(ip, { value, expiresAt: this.now() + (value ? this.ttlMs : this.negTtlMs) });
111
- return value;
112
155
  }
113
156
  put(ip, entry) {
114
157
  if (this.cache.size >= this.maxCache) {
@@ -120,3 +163,14 @@ export class RdapResolver {
120
163
  this.cache.set(ip, entry);
121
164
  }
122
165
  }
166
+ /** Parse a `Retry-After` header (delta-seconds form) into ms, capped at 5s so a
167
+ * hostile/huge value can't stall a batch. Ignores the HTTP-date form. */
168
+ function retryAfter(res) {
169
+ const raw = res.headers?.get?.("retry-after");
170
+ if (!raw)
171
+ return undefined;
172
+ const secs = Number(raw.trim());
173
+ if (!Number.isFinite(secs) || secs < 0)
174
+ return undefined;
175
+ return Math.min(secs * 1000, 5000);
176
+ }
@@ -76,3 +76,85 @@ describe("RdapResolver", () => {
76
76
  assert.equal(await r.lookup("8.8.8.8"), null);
77
77
  });
78
78
  });
79
+ // Issue #523: a rate-limit / upstream failure must be distinguishable from a
80
+ // true negative, and must NOT poison the cache as one.
81
+ describe("RdapResolver.resolve — transient vs true negative (#523)", () => {
82
+ const noSleep = async () => { };
83
+ it("maps a 200 hit to ok", async () => {
84
+ const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
85
+ const r = new RdapResolver({ fetch, sleep: noSleep });
86
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
87
+ });
88
+ it("maps a 404 to a true negative (not_found)", async () => {
89
+ const { fetch } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
90
+ const r = new RdapResolver({ fetch, sleep: noSleep });
91
+ assert.deepEqual(await r.resolve("203.0.113.7"), { status: "not_found" });
92
+ });
93
+ it("maps a 200 with no country/org to not_found, not a bogus hit", async () => {
94
+ const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: { handle: "x" } }));
95
+ const r = new RdapResolver({ fetch, sleep: noSleep });
96
+ assert.deepEqual(await r.resolve("203.0.113.8"), { status: "not_found" });
97
+ });
98
+ it("maps a 429 to transient:rate_limited after exhausting retries", async () => {
99
+ const { fetch, calls } = stubFetch(() => ({ ok: false, status: 429, body: {} }));
100
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
101
+ assert.deepEqual(await r.resolve("203.0.113.10"), { status: "transient", reason: "rate_limited" });
102
+ assert.equal(calls.length, 3, "1 initial + 2 retries");
103
+ });
104
+ it("maps a 5xx to transient:upstream_error", async () => {
105
+ const { fetch } = stubFetch(() => ({ ok: false, status: 503, body: {} }));
106
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
107
+ assert.deepEqual(await r.resolve("203.0.113.11"), { status: "transient", reason: "upstream_error" });
108
+ });
109
+ it("maps a thrown network error to transient:network_error", async () => {
110
+ const r = new RdapResolver({ fetch: (async () => { throw new Error("ECONNRESET"); }), sleep: noSleep, maxRetries: 0 });
111
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "network_error" });
112
+ });
113
+ it("does NOT cache a transient failure — a later success resolves", async () => {
114
+ let mode = "throttle";
115
+ const calls = [];
116
+ const fetch = async (url) => {
117
+ calls.push(url);
118
+ return mode === "throttle"
119
+ ? { ok: false, status: 429, json: async () => ({}) }
120
+ : { ok: true, status: 200, json: async () => RDAP_GOOGLE };
121
+ };
122
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
123
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "rate_limited" });
124
+ mode = "ok";
125
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
126
+ assert.equal(calls.length, 2, "transient was not cached, so the retry re-fetched");
127
+ });
128
+ it("retries a transient then succeeds, returning ok", async () => {
129
+ let n = 0;
130
+ const fetch = async () => {
131
+ n++;
132
+ return n === 1
133
+ ? { ok: false, status: 429, json: async () => ({}) }
134
+ : { ok: true, status: 200, json: async () => RDAP_GOOGLE };
135
+ };
136
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
137
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
138
+ assert.equal(n, 2, "succeeded on the first retry");
139
+ });
140
+ it("honors a numeric Retry-After for backoff (capped)", async () => {
141
+ const slept = [];
142
+ let n = 0;
143
+ const fetch = async () => {
144
+ n++;
145
+ if (n === 1)
146
+ return { ok: false, status: 429, json: async () => ({}), headers: { get: (h) => (h.toLowerCase() === "retry-after" ? "2" : null) } };
147
+ return { ok: true, status: 200, json: async () => RDAP_GOOGLE };
148
+ };
149
+ const r = new RdapResolver({ fetch, sleep: async (ms) => { slept.push(ms); }, maxRetries: 1 });
150
+ await r.resolve("8.8.8.8");
151
+ assert.deepEqual(slept, [2000], "waited the Retry-After delta (2s) before retrying");
152
+ });
153
+ it("caches a true negative (no re-fetch within negTtl)", async () => {
154
+ const { fetch, calls } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
155
+ const r = new RdapResolver({ fetch, sleep: noSleep });
156
+ await r.resolve("203.0.113.7");
157
+ await r.resolve("203.0.113.7");
158
+ assert.equal(calls.length, 1, "negative cached");
159
+ });
160
+ });
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);
@@ -794,6 +799,7 @@ async function main() {
794
799
  "Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
795
800
  "When to use: answering 'where are these visitors from?' or 'which of these IPs are bots / datacenter / VPN exit nodes?' over access logs, without an out-of-band geo-API call per IP. Both IPv4 and IPv6 clients are resolved — don't pre-filter v6 out.",
796
801
  "Behavior: read-only. By default looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) with NO external network call — safe in air-gapped deployments. Optionally, if the operator enabled OMCP_IP_ENRICH_RDAP, IPs the dataset doesn't cover fall back to an online RDAP query (country/org only) and the result carries via:'rdap'; the offline dataset is always preferred. Returns one row per input IP with found=true/false plus any known fields. If neither is configured it returns a clear notice explaining how to enable them.",
802
+ "RDAP rate-limits: a row with found=false AND transient:true (error names the cause, e.g. 'rate_limited') is NOT a confirmed negative — the registry throttled or failed the lookup, so the IP may resolve on a later retry or in a smaller batch. Such rows are counted in summary.transient (separate from summary.unmatched) and a top-level `note` is added. Don't treat transient rows as 'unknown/suspicious'; retry them (results are cached, so repeats are cheap).",
797
803
  "Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
798
804
  ].join(" "), {
799
805
  ips: z
@@ -1848,6 +1854,7 @@ async function main() {
1848
1854
  productId: c.productId,
1849
1855
  bypassRedaction: !!c.bypassRedaction,
1850
1856
  allowedSources: c.allowedSources,
1857
+ allowedTools: c.allowedTools,
1851
1858
  });
1852
1859
  }
1853
1860
  // OIDC groups → role mappings.
@@ -3669,6 +3676,11 @@ async function main() {
3669
3676
  allowRawQuery: cred.allowRawQuery,
3670
3677
  tenant: cred.tenant,
3671
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,
3672
3684
  });
3673
3685
  }
3674
3686
  app.post("/mcp", async (req, res) => {
@@ -3740,14 +3752,6 @@ async function main() {
3740
3752
  // registerTool gate, so the surface a /mcp/v/<slug> client sees is
3741
3753
  // strictly product.tools (intersected with any pre-existing
3742
3754
  // allowedTools the credential already carries).
3743
- function intersectAllowed(a, b) {
3744
- if (!a)
3745
- return b;
3746
- if (!b)
3747
- return a;
3748
- const bSet = new Set(b);
3749
- return a.filter((t) => bSet.has(t));
3750
- }
3751
3755
  async function resolveVirtualProduct(req, res, baseCtx) {
3752
3756
  const slug = req.params.slug;
3753
3757
  if (!slug || typeof slug !== "string") {
@@ -3910,6 +3914,7 @@ async function main() {
3910
3914
  allowRawQuery: cred.allowRawQuery,
3911
3915
  tenant: cred.tenant,
3912
3916
  allowedTools,
3917
+ credentialTools: cred.allowedTools,
3913
3918
  }),
3914
3919
  selectedSubprotocol,
3915
3920
  };
@@ -19,6 +19,12 @@ export interface IpEnrichmentResult {
19
19
  /** Which backend produced the hit — "dataset" (offline CSV) or "rdap"
20
20
  * (online fallback). Absent when not found. */
21
21
  via?: "dataset" | "rdap";
22
+ /** True when `found:false` is NOT a confirmed negative but an RDAP upstream
23
+ * failure (throttle/timeout/5xx) — the address may resolve on a later retry.
24
+ * Issue #523: never conflate a rate-limit with "not in any registry". */
25
+ transient?: boolean;
26
+ /** Machine-readable reason when `transient` — e.g. "rate_limited". */
27
+ error?: string;
22
28
  }
23
29
  export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext, rdap?: RdapResolver | null): Promise<{
24
30
  content: {
@@ -38,6 +38,7 @@ rdap) {
38
38
  let invalid = 0;
39
39
  let matched = 0;
40
40
  let viaRdap = 0;
41
+ let transient = 0;
41
42
  for (const ip of ips) {
42
43
  if (typeof ip !== "string" || !isValidIp(ip)) {
43
44
  invalid++;
@@ -53,16 +54,26 @@ rdap) {
53
54
  continue;
54
55
  }
55
56
  if (rdap) {
56
- const r = await rdap.lookup(ip);
57
- if (r) {
57
+ const r = await rdap.resolve(ip);
58
+ if (r.status === "ok") {
58
59
  matched++;
59
60
  viaRdap++;
60
- results.push({ ip, found: true, via: "rdap", ...r });
61
+ results.push({ ip, found: true, via: "rdap", ...r.value });
62
+ continue;
63
+ }
64
+ if (r.status === "transient") {
65
+ // NOT a confirmed negative — an RDAP throttle/timeout/5xx. Mark it so an
66
+ // agent doesn't treat the IP as "unknown" and can retry later (#523).
67
+ transient++;
68
+ results.push({ ip, found: false, transient: true, error: r.reason });
61
69
  continue;
62
70
  }
63
71
  }
64
72
  results.push({ ip, found: false });
65
73
  }
74
+ // unmatched = confirmed negatives only; transient failures are reported
75
+ // separately so the all-clear can't silently absorb a wall of rate-limits.
76
+ const unmatched = ips.length - matched - invalid - transient;
66
77
  return {
67
78
  content: [
68
79
  {
@@ -72,12 +83,19 @@ rdap) {
72
83
  summary: {
73
84
  total: ips.length,
74
85
  matched,
75
- unmatched: ips.length - matched - invalid,
86
+ unmatched,
76
87
  invalid,
77
- ...(rdap ? { viaRdap } : {}),
88
+ ...(rdap ? { viaRdap, transient } : {}),
78
89
  },
79
90
  datasetSize: dataset?.size ?? 0,
80
91
  ...(rdap ? { rdapEnabled: true } : {}),
92
+ ...(transient > 0
93
+ ? {
94
+ note: `${transient} RDAP lookup(s) failed transiently (e.g. rate-limited by the ` +
95
+ `registry) and are marked transient:true — these are NOT confirmed negatives. ` +
96
+ `Retry them later or in a smaller batch; results are cached so repeats are cheap.`,
97
+ }
98
+ : {}),
81
99
  }, null, 2),
82
100
  },
83
101
  ],
@@ -48,13 +48,21 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
48
48
  });
49
49
  });
50
50
  describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
51
- // Minimal RdapResolver stub: returns a fixed hit for one IP, null otherwise,
52
- // and records which IPs it was asked about (to prove CSV-first).
53
- function rdapStub(hit) {
51
+ // Minimal RdapResolver stub: returns a fixed hit for one IP, a true negative
52
+ // otherwise, and records which IPs it was asked about (to prove CSV-first).
53
+ // `transient` IPs resolve to a transient outcome (e.g. rate-limited) — #523.
54
+ function rdapStub(hit, transient = {}) {
54
55
  const asked = [];
55
56
  return {
56
57
  asked,
57
- resolver: { lookup: async (ip) => { asked.push(ip); return hit[ip] ?? null; } },
58
+ resolver: {
59
+ resolve: async (ip) => {
60
+ asked.push(ip);
61
+ if (transient[ip])
62
+ return { status: "transient", reason: transient[ip] };
63
+ return hit[ip] ? { status: "ok", value: hit[ip] } : { status: "not_found" };
64
+ },
65
+ },
58
66
  };
59
67
  }
60
68
  it("with no dataset but RDAP enabled → not 'not configured'; resolves via RDAP", async () => {
@@ -90,4 +98,29 @@ describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
90
98
  assert.match(out.error, /not configured/i);
91
99
  assert.match(out.error, /OMCP_IP_ENRICH_RDAP/);
92
100
  });
101
+ // Issue #523: a rate-limited lookup must NOT masquerade as a confirmed negative.
102
+ it("marks a rate-limited lookup transient (not a confirmed negative)", async () => {
103
+ const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } }, { "203.0.113.10": "rate_limited" });
104
+ const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.10"] }, undefined, resolver));
105
+ const hit = out.results.find((r) => r.ip === "8.8.8.8");
106
+ assert.equal(hit.found, true);
107
+ const throttled = out.results.find((r) => r.ip === "203.0.113.10");
108
+ assert.equal(throttled.found, false);
109
+ assert.equal(throttled.transient, true);
110
+ assert.equal(throttled.error, "rate_limited");
111
+ // The throttled IP is counted as transient, NOT folded into `unmatched`.
112
+ assert.equal(out.summary.matched, 1);
113
+ assert.equal(out.summary.transient, 1);
114
+ assert.equal(out.summary.unmatched, 0);
115
+ assert.match(out.note, /NOT confirmed negatives/i);
116
+ });
117
+ it("a genuine miss stays a clean negative — no transient marker, no note", async () => {
118
+ const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } });
119
+ const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.9"] }, undefined, resolver));
120
+ const miss = out.results.find((r) => r.ip === "203.0.113.9");
121
+ assert.equal(miss.found, false);
122
+ assert.equal(miss.transient, undefined);
123
+ assert.equal(out.summary.transient, 0);
124
+ assert.equal(out.note, undefined);
125
+ });
93
126
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.8.2",
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",