@thotischner/observability-mcp 1.7.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.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- package/package.json +32 -5
|
@@ -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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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 ??
|
|
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
|
-
|
|
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({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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({
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
114
|
+
...fakeSource,
|
|
97
115
|
metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
|
|
98
116
|
});
|
|
99
|
-
const
|
|
100
|
-
assert.equal(
|
|
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
|
|
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.
|
|
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
|
@@ -18,10 +18,54 @@ export interface RequestContext {
|
|
|
18
18
|
* scoping (tools/services/lookback/read-only) is a separate concern.
|
|
19
19
|
*/
|
|
20
20
|
allowedSources?: string[];
|
|
21
|
+
/** When true, the credential is allowed to opt out of redaction on a
|
|
22
|
+
* per-tool-call basis. The actual bypass is engaged only when the
|
|
23
|
+
* tool call ALSO sets `bypass_redaction: true` in its args. Default
|
|
24
|
+
* false. Configured via OMCP_KEY_BYPASS_REDACTION. */
|
|
25
|
+
allowBypassRedaction?: boolean;
|
|
26
|
+
/** Tenant the request operates in. ALWAYS set — defaults to
|
|
27
|
+
* "default" for anonymous principals + missing-tenant credentials,
|
|
28
|
+
* preserving the single-namespace behaviour of pre-E7 deployments. */
|
|
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[];
|
|
21
36
|
/** Correlates all tool calls within one transport request/session. */
|
|
22
37
|
correlationId: string;
|
|
23
38
|
}
|
|
24
39
|
/** Default all-access anonymous context — preserves current behaviour. */
|
|
25
40
|
export declare function defaultContext(): RequestContext;
|
|
26
41
|
/** Context for an authenticated API-key principal. */
|
|
27
|
-
export declare function principalContext(principalId: string, allowedSources?: string[]
|
|
42
|
+
export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
|
|
43
|
+
allowBypassRedaction?: boolean;
|
|
44
|
+
tenant?: string;
|
|
45
|
+
allowedTools?: string[];
|
|
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
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DEFAULT_TENANT, normaliseTenant } from "./tenancy/context.js";
|
|
2
3
|
/** Default all-access anonymous context — preserves current behaviour. */
|
|
3
4
|
export function defaultContext() {
|
|
4
5
|
return {
|
|
5
6
|
principalId: "anonymous",
|
|
6
7
|
auth: "anonymous",
|
|
8
|
+
tenant: DEFAULT_TENANT,
|
|
7
9
|
correlationId: randomUUID(),
|
|
8
10
|
};
|
|
9
11
|
}
|
|
10
12
|
/** Context for an authenticated API-key principal. */
|
|
11
|
-
export function principalContext(principalId, allowedSources) {
|
|
13
|
+
export function principalContext(principalId, allowedSources, opts = {}) {
|
|
12
14
|
return {
|
|
13
15
|
principalId,
|
|
14
16
|
auth: "apikey",
|
|
15
17
|
allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
|
|
18
|
+
allowBypassRedaction: opts.allowBypassRedaction || undefined,
|
|
19
|
+
tenant: normaliseTenant(opts.tenant),
|
|
20
|
+
allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
|
|
16
21
|
correlationId: randomUUID(),
|
|
17
22
|
};
|
|
18
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 {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { allowsTool, defaultContext, principalContext } from "./context.js";
|
|
4
|
+
test("allowsTool — undefined allow-list = no Product binding = every tool allowed", () => {
|
|
5
|
+
assert.equal(allowsTool(undefined, "list_sources"), true);
|
|
6
|
+
assert.equal(allowsTool(undefined, "query_logs"), true);
|
|
7
|
+
});
|
|
8
|
+
test("allowsTool — empty allow-list = Product with no tools field = every tool allowed", () => {
|
|
9
|
+
assert.equal(allowsTool([], "list_sources"), true);
|
|
10
|
+
});
|
|
11
|
+
test("allowsTool — non-empty allow-list gates by exact match", () => {
|
|
12
|
+
const allow = ["list_sources", "query_metrics"];
|
|
13
|
+
assert.equal(allowsTool(allow, "list_sources"), true);
|
|
14
|
+
assert.equal(allowsTool(allow, "query_metrics"), true);
|
|
15
|
+
assert.equal(allowsTool(allow, "query_logs"), false);
|
|
16
|
+
assert.equal(allowsTool(allow, "get_topology"), false);
|
|
17
|
+
});
|
|
18
|
+
test("allowsTool — case-sensitive (matches MCP spec)", () => {
|
|
19
|
+
const allow = ["list_sources"];
|
|
20
|
+
assert.equal(allowsTool(allow, "List_Sources"), false);
|
|
21
|
+
});
|
|
22
|
+
test("principalContext — passes allowedTools through; empty array → undefined", () => {
|
|
23
|
+
const ctx1 = principalContext("agent", undefined, { allowedTools: ["query_logs"] });
|
|
24
|
+
assert.deepEqual(ctx1.allowedTools, ["query_logs"]);
|
|
25
|
+
// Empty array carries the "no restriction" semantic — we normalise
|
|
26
|
+
// to undefined so allowsTool() takes the back-compat short path.
|
|
27
|
+
const ctx2 = principalContext("agent", undefined, { allowedTools: [] });
|
|
28
|
+
assert.equal(ctx2.allowedTools, undefined);
|
|
29
|
+
const ctx3 = principalContext("agent");
|
|
30
|
+
assert.equal(ctx3.allowedTools, undefined);
|
|
31
|
+
});
|
|
32
|
+
test("defaultContext — no allowedTools (anonymous sees every tool, back-compat)", () => {
|
|
33
|
+
const ctx = defaultContext();
|
|
34
|
+
assert.equal(ctx.allowedTools, undefined);
|
|
35
|
+
assert.equal(allowsTool(ctx.allowedTools, "any_tool"), true);
|
|
36
|
+
});
|
|
37
|
+
import { sessionContext } from "./context.js";
|
|
38
|
+
test("sessionContext — undefined session → defaultContext shape (anonymous, default tenant)", () => {
|
|
39
|
+
const ctx = sessionContext(undefined);
|
|
40
|
+
assert.equal(ctx.auth, "anonymous");
|
|
41
|
+
assert.equal(ctx.tenant, "default");
|
|
42
|
+
assert.equal(ctx.principalId, "anonymous");
|
|
43
|
+
});
|
|
44
|
+
test("sessionContext — session.tenant flows into ctx.tenant (the load-bearing property for /api/services + /api/health)", () => {
|
|
45
|
+
const ctx = sessionContext({ sub: "alice", name: "Alice", tenant: "acme" });
|
|
46
|
+
assert.equal(ctx.tenant, "acme");
|
|
47
|
+
assert.equal(ctx.principalId, "alice");
|
|
48
|
+
assert.equal(ctx.auth, "apikey");
|
|
49
|
+
});
|
|
50
|
+
test("sessionContext — falls back to session.name when sub absent", () => {
|
|
51
|
+
const ctx = sessionContext({ name: "operator-bot", tenant: "bigco" });
|
|
52
|
+
assert.equal(ctx.principalId, "operator-bot");
|
|
53
|
+
});
|
|
54
|
+
test("sessionContext — sessionless tenant inherits DEFAULT (no leak from a previous tenant'd request)", () => {
|
|
55
|
+
// Belt-and-suspenders: explicit empty tenant string normalises to default.
|
|
56
|
+
const ctx = sessionContext({ sub: "u", tenant: "" });
|
|
57
|
+
assert.equal(ctx.tenant, "default");
|
|
58
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UpstreamClient, UpstreamToolInfo } from "./upstream.js";
|
|
2
|
+
export declare class FederationRegistry {
|
|
3
|
+
private upstreams;
|
|
4
|
+
add(client: UpstreamClient): void;
|
|
5
|
+
remove(name: string): void;
|
|
6
|
+
get(name: string): UpstreamClient | undefined;
|
|
7
|
+
list(): UpstreamClient[];
|
|
8
|
+
/** Flat, namespaced tool view across every connected upstream. */
|
|
9
|
+
getNamespacedTools(): UpstreamToolInfo[];
|
|
10
|
+
/** Dispatch a namespaced tool call to the right upstream. The
|
|
11
|
+
* namespaced name MUST exist in the catalog; the caller (the
|
|
12
|
+
* registerTool wrapper in createMcpServer) is responsible for not
|
|
13
|
+
* routing tools that aren't there. */
|
|
14
|
+
callNamespacedTool(namespacedName: string, args: unknown): Promise<unknown>;
|
|
15
|
+
closeAll(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse the OMCP_FEDERATION_UPSTREAMS env into a list of upstream
|
|
19
|
+
* configs. Shape:
|
|
20
|
+
*
|
|
21
|
+
* "name1=https://gw.a/mcp,name2=https://gw.b/mcp"
|
|
22
|
+
*
|
|
23
|
+
* Each upstream's bearer token is read from
|
|
24
|
+
* OMCP_FEDERATION_TOKEN_<UPPERCASE-NAME> (dots → underscores), so
|
|
25
|
+
* tokens stay out of the URL list itself.
|
|
26
|
+
*/
|
|
27
|
+
export interface ParsedUpstream {
|
|
28
|
+
name: string;
|
|
29
|
+
url: string;
|
|
30
|
+
bearerToken?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function parseFederationEnv(env?: NodeJS.ProcessEnv): ParsedUpstream[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// FederationRegistry — collects every UpstreamClient + exposes a
|
|
2
|
+
// flat view of namespaced tools across them. createMcpServer reads
|
|
3
|
+
// `getNamespacedTools()` on each per-session instantiation and
|
|
4
|
+
// registers a proxy handler for each one that calls
|
|
5
|
+
// `callNamespacedTool()`.
|
|
6
|
+
export class FederationRegistry {
|
|
7
|
+
upstreams = new Map();
|
|
8
|
+
add(client) {
|
|
9
|
+
if (this.upstreams.has(client.name)) {
|
|
10
|
+
throw new Error(`federation upstream ${client.name} already registered`);
|
|
11
|
+
}
|
|
12
|
+
this.upstreams.set(client.name, client);
|
|
13
|
+
}
|
|
14
|
+
remove(name) {
|
|
15
|
+
this.upstreams.delete(name);
|
|
16
|
+
}
|
|
17
|
+
get(name) {
|
|
18
|
+
return this.upstreams.get(name);
|
|
19
|
+
}
|
|
20
|
+
list() {
|
|
21
|
+
return [...this.upstreams.values()];
|
|
22
|
+
}
|
|
23
|
+
/** Flat, namespaced tool view across every connected upstream. */
|
|
24
|
+
getNamespacedTools() {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const client of this.upstreams.values()) {
|
|
27
|
+
out.push(...client.getTools());
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** Dispatch a namespaced tool call to the right upstream. The
|
|
32
|
+
* namespaced name MUST exist in the catalog; the caller (the
|
|
33
|
+
* registerTool wrapper in createMcpServer) is responsible for not
|
|
34
|
+
* routing tools that aren't there. */
|
|
35
|
+
async callNamespacedTool(namespacedName, args) {
|
|
36
|
+
for (const client of this.upstreams.values()) {
|
|
37
|
+
const match = client.getTools().find((t) => t.namespacedName === namespacedName);
|
|
38
|
+
if (match)
|
|
39
|
+
return client.callTool(match.upstreamName, args);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`federated tool not found: ${namespacedName}`);
|
|
42
|
+
}
|
|
43
|
+
async closeAll() {
|
|
44
|
+
await Promise.all([...this.upstreams.values()].map((c) => c.close()));
|
|
45
|
+
this.upstreams.clear();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function parseFederationEnv(env = process.env) {
|
|
49
|
+
const raw = env.OMCP_FEDERATION_UPSTREAMS?.trim();
|
|
50
|
+
if (!raw)
|
|
51
|
+
return [];
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const part of raw.split(",")) {
|
|
54
|
+
const trimmed = part.trim();
|
|
55
|
+
if (!trimmed)
|
|
56
|
+
continue;
|
|
57
|
+
const eq = trimmed.indexOf("=");
|
|
58
|
+
if (eq < 0) {
|
|
59
|
+
console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${trimmed}" missing "=" — skipping`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const name = trimmed.slice(0, eq).trim();
|
|
63
|
+
const url = trimmed.slice(eq + 1).trim();
|
|
64
|
+
if (!/^[a-z][a-z0-9_-]*$/i.test(name)) {
|
|
65
|
+
console.warn(`OMCP_FEDERATION_UPSTREAMS entry name "${name}" is invalid — skipping`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!/^https?:\/\//.test(url)) {
|
|
69
|
+
console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${name}" url "${url}" must start with http:// or https:// — skipping`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const tokenEnv = `OMCP_FEDERATION_TOKEN_${name.toUpperCase().replace(/[-.]/g, "_")}`;
|
|
73
|
+
const bearerToken = env[tokenEnv]?.trim() || undefined;
|
|
74
|
+
entries.push({ name, url, bearerToken });
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|