@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { FederationRegistry, parseFederationEnv } from "./registry.js";
|
|
4
|
+
// Minimal fake UpstreamClient. The real Client requires a live HTTP
|
|
5
|
+
// upstream; this fake satisfies the shape FederationRegistry actually
|
|
6
|
+
// touches.
|
|
7
|
+
class FakeUpstream {
|
|
8
|
+
name;
|
|
9
|
+
url = "https://fake/mcp";
|
|
10
|
+
namespacePrefix;
|
|
11
|
+
tools;
|
|
12
|
+
callLog = [];
|
|
13
|
+
closed = false;
|
|
14
|
+
constructor(name, prefix, toolNames) {
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.namespacePrefix = prefix;
|
|
17
|
+
this.tools = toolNames.map((n) => ({
|
|
18
|
+
namespacedName: `${prefix}.${n}`,
|
|
19
|
+
upstreamName: n,
|
|
20
|
+
sourceName: name,
|
|
21
|
+
description: `upstream tool ${n}`,
|
|
22
|
+
inputSchema: {},
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
getTools() {
|
|
26
|
+
return [...this.tools];
|
|
27
|
+
}
|
|
28
|
+
async callTool(upstreamName, args) {
|
|
29
|
+
this.callLog.push({ tool: upstreamName, args });
|
|
30
|
+
return { result: { echo: upstreamName, args } };
|
|
31
|
+
}
|
|
32
|
+
async close() {
|
|
33
|
+
this.closed = true;
|
|
34
|
+
}
|
|
35
|
+
getStatus() {
|
|
36
|
+
return { status: "ready", toolCount: this.tools.length };
|
|
37
|
+
}
|
|
38
|
+
async connect() {
|
|
39
|
+
/* no-op for tests */
|
|
40
|
+
}
|
|
41
|
+
async refresh() {
|
|
42
|
+
/* no-op */
|
|
43
|
+
}
|
|
44
|
+
log() {
|
|
45
|
+
return this.callLog;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function fakeAsClient(f) {
|
|
49
|
+
return f;
|
|
50
|
+
}
|
|
51
|
+
test("FederationRegistry: add + list + get", () => {
|
|
52
|
+
const reg = new FederationRegistry();
|
|
53
|
+
const u = new FakeUpstream("a", "a", ["x"]);
|
|
54
|
+
reg.add(fakeAsClient(u));
|
|
55
|
+
assert.equal(reg.list().length, 1);
|
|
56
|
+
assert.equal(reg.get("a")?.name, "a");
|
|
57
|
+
});
|
|
58
|
+
test("FederationRegistry: add of duplicate name throws", () => {
|
|
59
|
+
const reg = new FederationRegistry();
|
|
60
|
+
reg.add(fakeAsClient(new FakeUpstream("a", "a", [])));
|
|
61
|
+
assert.throws(() => reg.add(fakeAsClient(new FakeUpstream("a", "a", []))), /already registered/);
|
|
62
|
+
});
|
|
63
|
+
test("FederationRegistry: getNamespacedTools flattens across upstreams with stable order", () => {
|
|
64
|
+
const reg = new FederationRegistry();
|
|
65
|
+
reg.add(fakeAsClient(new FakeUpstream("a", "a", ["x", "y"])));
|
|
66
|
+
reg.add(fakeAsClient(new FakeUpstream("b", "b", ["z"])));
|
|
67
|
+
const names = reg.getNamespacedTools().map((t) => t.namespacedName);
|
|
68
|
+
assert.deepEqual(names, ["a.x", "a.y", "b.z"]);
|
|
69
|
+
});
|
|
70
|
+
test("FederationRegistry: callNamespacedTool routes to the owning upstream", async () => {
|
|
71
|
+
const a = new FakeUpstream("a", "a", ["x"]);
|
|
72
|
+
const b = new FakeUpstream("b", "b", ["z"]);
|
|
73
|
+
const reg = new FederationRegistry();
|
|
74
|
+
reg.add(fakeAsClient(a));
|
|
75
|
+
reg.add(fakeAsClient(b));
|
|
76
|
+
await reg.callNamespacedTool("b.z", { foo: 1 });
|
|
77
|
+
assert.equal(a.log().length, 0);
|
|
78
|
+
assert.deepEqual(b.log(), [{ tool: "z", args: { foo: 1 } }]);
|
|
79
|
+
});
|
|
80
|
+
test("FederationRegistry: callNamespacedTool throws on unknown tool", async () => {
|
|
81
|
+
const reg = new FederationRegistry();
|
|
82
|
+
reg.add(fakeAsClient(new FakeUpstream("a", "a", ["x"])));
|
|
83
|
+
await assert.rejects(() => reg.callNamespacedTool("a.nope", {}), /not found/);
|
|
84
|
+
});
|
|
85
|
+
test("FederationRegistry: remove + closeAll", async () => {
|
|
86
|
+
const a = new FakeUpstream("a", "a", []);
|
|
87
|
+
const reg = new FederationRegistry();
|
|
88
|
+
reg.add(fakeAsClient(a));
|
|
89
|
+
reg.remove("a");
|
|
90
|
+
assert.equal(reg.list().length, 0);
|
|
91
|
+
const b = new FakeUpstream("b", "b", []);
|
|
92
|
+
reg.add(fakeAsClient(b));
|
|
93
|
+
await reg.closeAll();
|
|
94
|
+
assert.equal(b.closed, true);
|
|
95
|
+
assert.equal(reg.list().length, 0);
|
|
96
|
+
});
|
|
97
|
+
test("parseFederationEnv: returns [] for missing / empty", () => {
|
|
98
|
+
assert.deepEqual(parseFederationEnv({}), []);
|
|
99
|
+
assert.deepEqual(parseFederationEnv({ OMCP_FEDERATION_UPSTREAMS: "" }), []);
|
|
100
|
+
assert.deepEqual(parseFederationEnv({ OMCP_FEDERATION_UPSTREAMS: " " }), []);
|
|
101
|
+
});
|
|
102
|
+
test("parseFederationEnv: parses name=url comma-separated entries", () => {
|
|
103
|
+
const parsed = parseFederationEnv({
|
|
104
|
+
OMCP_FEDERATION_UPSTREAMS: "a=https://gw.a/mcp,b=https://gw.b/mcp",
|
|
105
|
+
});
|
|
106
|
+
assert.deepEqual(parsed, [
|
|
107
|
+
{ name: "a", url: "https://gw.a/mcp", bearerToken: undefined },
|
|
108
|
+
{ name: "b", url: "https://gw.b/mcp", bearerToken: undefined },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
test("parseFederationEnv: picks up bearer token per OMCP_FEDERATION_TOKEN_<NAME>", () => {
|
|
112
|
+
const parsed = parseFederationEnv({
|
|
113
|
+
OMCP_FEDERATION_UPSTREAMS: "prod=https://gw.prod/mcp",
|
|
114
|
+
OMCP_FEDERATION_TOKEN_PROD: "secret-token-xyz",
|
|
115
|
+
});
|
|
116
|
+
assert.equal(parsed[0]?.bearerToken, "secret-token-xyz");
|
|
117
|
+
});
|
|
118
|
+
test("parseFederationEnv: skips malformed entries with a warning, keeps the rest", () => {
|
|
119
|
+
const parsed = parseFederationEnv({
|
|
120
|
+
OMCP_FEDERATION_UPSTREAMS: "good=https://gw/mcp,no-equals,bad-url=ftp://x,a=https://b/mcp",
|
|
121
|
+
});
|
|
122
|
+
// Only `good=` and `a=` survive
|
|
123
|
+
assert.deepEqual(parsed.map((p) => p.name), ["good", "a"]);
|
|
124
|
+
});
|
|
125
|
+
test("parseFederationEnv: rejects invalid names (must start with letter)", () => {
|
|
126
|
+
const parsed = parseFederationEnv({
|
|
127
|
+
OMCP_FEDERATION_UPSTREAMS: "1bad=https://x/mcp,_also=https://y/mcp,ok=https://z/mcp",
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(parsed.map((p) => p.name), ["ok"]);
|
|
130
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface UpstreamConfig {
|
|
2
|
+
/** Stable source name (used in the namespace prefix + audit entries). */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Upstream Streamable HTTP URL (must end at /mcp). */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Static bearer token sent on every outbound call. */
|
|
7
|
+
bearerToken?: string;
|
|
8
|
+
/** Tool-name prefix; default = source name. Resulting registered
|
|
9
|
+
* tool name is `<prefix>.<upstream-tool-name>`. */
|
|
10
|
+
namespacePrefix?: string;
|
|
11
|
+
/** ms between automatic catalog refreshes. Default 5 minutes;
|
|
12
|
+
* 0 disables auto-refresh (manual refresh() only). */
|
|
13
|
+
refreshIntervalMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface UpstreamToolInfo {
|
|
16
|
+
/** Local namespaced name: `<prefix>.<upstreamName>`. */
|
|
17
|
+
namespacedName: string;
|
|
18
|
+
/** Original name on the upstream. */
|
|
19
|
+
upstreamName: string;
|
|
20
|
+
/** Upstream source name (audit attribution + diagnostics). */
|
|
21
|
+
sourceName: string;
|
|
22
|
+
/** Tool description as the upstream advertises it. */
|
|
23
|
+
description: string;
|
|
24
|
+
/** Upstream's inputSchema, forwarded verbatim. */
|
|
25
|
+
inputSchema: unknown;
|
|
26
|
+
}
|
|
27
|
+
export type UpstreamStatus = "connecting" | "ready" | "degraded" | "disconnected";
|
|
28
|
+
export declare class UpstreamClient {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly namespacePrefix: string;
|
|
32
|
+
private bearerToken?;
|
|
33
|
+
private client?;
|
|
34
|
+
private transport?;
|
|
35
|
+
private toolsCache;
|
|
36
|
+
private status;
|
|
37
|
+
private lastError?;
|
|
38
|
+
private refreshTimer?;
|
|
39
|
+
private refreshIntervalMs;
|
|
40
|
+
constructor(cfg: UpstreamConfig);
|
|
41
|
+
getStatus(): {
|
|
42
|
+
status: UpstreamStatus;
|
|
43
|
+
lastError?: string;
|
|
44
|
+
toolCount: number;
|
|
45
|
+
};
|
|
46
|
+
/** Cached catalog (read-only). */
|
|
47
|
+
getTools(): UpstreamToolInfo[];
|
|
48
|
+
/** Connect + initial catalog fetch. Logs failures and leaves the
|
|
49
|
+
* client in `degraded` so the catalog stays empty rather than
|
|
50
|
+
* blocking startup. Re-runnable. */
|
|
51
|
+
connect(): Promise<void>;
|
|
52
|
+
/** Re-fetch the upstream tool catalog. Throws on failure so the
|
|
53
|
+
* caller can choose to degrade or retry. */
|
|
54
|
+
refresh(): Promise<void>;
|
|
55
|
+
/** Forward a callTool request to the upstream by upstream-tool name
|
|
56
|
+
* (NOT the namespaced name — the registry strips the prefix before
|
|
57
|
+
* calling here). */
|
|
58
|
+
callTool(upstreamName: string, args: unknown): Promise<unknown>;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Upstream MCP federation client.
|
|
2
|
+
//
|
|
3
|
+
// One UpstreamClient per remote MCP gateway. On connect() it runs the
|
|
4
|
+
// MCP initialize handshake, fetches tools/list, and caches the catalog
|
|
5
|
+
// locally. callTool() forwards a request to the upstream and returns
|
|
6
|
+
// its CallToolResult verbatim.
|
|
7
|
+
//
|
|
8
|
+
// Transport: Streamable HTTP only in this slice. Stdio + WebSocket
|
|
9
|
+
// upstreams are deferred (the SDK already provides client transports
|
|
10
|
+
// for both; wiring them is mechanical once the routing logic settles).
|
|
11
|
+
//
|
|
12
|
+
// Auth forwarding modes:
|
|
13
|
+
// - "none" — no auth header on outbound calls
|
|
14
|
+
// - "bearer" — static OMCP_FEDERATION_TOKEN_<NAME> sent as Bearer
|
|
15
|
+
//
|
|
16
|
+
// OIDC + UAID passthrough are deferred — they require a per-request
|
|
17
|
+
// identity hand-off the federation manager doesn't carry today.
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
20
|
+
export class UpstreamClient {
|
|
21
|
+
name;
|
|
22
|
+
url;
|
|
23
|
+
namespacePrefix;
|
|
24
|
+
bearerToken;
|
|
25
|
+
client;
|
|
26
|
+
transport;
|
|
27
|
+
toolsCache = [];
|
|
28
|
+
status = "disconnected";
|
|
29
|
+
lastError;
|
|
30
|
+
refreshTimer;
|
|
31
|
+
refreshIntervalMs;
|
|
32
|
+
constructor(cfg) {
|
|
33
|
+
this.name = cfg.name;
|
|
34
|
+
this.url = cfg.url;
|
|
35
|
+
this.namespacePrefix = cfg.namespacePrefix ?? cfg.name;
|
|
36
|
+
this.bearerToken = cfg.bearerToken;
|
|
37
|
+
this.refreshIntervalMs = cfg.refreshIntervalMs ?? 5 * 60 * 1000;
|
|
38
|
+
}
|
|
39
|
+
getStatus() {
|
|
40
|
+
return { status: this.status, lastError: this.lastError, toolCount: this.toolsCache.length };
|
|
41
|
+
}
|
|
42
|
+
/** Cached catalog (read-only). */
|
|
43
|
+
getTools() {
|
|
44
|
+
return [...this.toolsCache];
|
|
45
|
+
}
|
|
46
|
+
/** Connect + initial catalog fetch. Logs failures and leaves the
|
|
47
|
+
* client in `degraded` so the catalog stays empty rather than
|
|
48
|
+
* blocking startup. Re-runnable. */
|
|
49
|
+
async connect() {
|
|
50
|
+
this.status = "connecting";
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(this.url);
|
|
53
|
+
const init = { headers: {} };
|
|
54
|
+
if (this.bearerToken) {
|
|
55
|
+
init.headers["Authorization"] = `Bearer ${this.bearerToken}`;
|
|
56
|
+
}
|
|
57
|
+
this.transport = new StreamableHTTPClientTransport(url, { requestInit: init });
|
|
58
|
+
this.client = new Client({ name: "observability-mcp-federation", version: "1" }, { capabilities: {} });
|
|
59
|
+
await this.client.connect(this.transport);
|
|
60
|
+
await this.refresh();
|
|
61
|
+
this.status = "ready";
|
|
62
|
+
this.lastError = undefined;
|
|
63
|
+
if (this.refreshIntervalMs > 0) {
|
|
64
|
+
this.refreshTimer = setInterval(() => {
|
|
65
|
+
this.refresh().catch((err) => {
|
|
66
|
+
console.warn("UpstreamClient %s: background refresh failed: %s", this.name, err instanceof Error ? err.message : String(err));
|
|
67
|
+
});
|
|
68
|
+
}, this.refreshIntervalMs).unref?.();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
this.status = "degraded";
|
|
73
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
74
|
+
console.warn("UpstreamClient %s: connect failed (%s). Federation continues without this upstream.", this.name, this.lastError);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Re-fetch the upstream tool catalog. Throws on failure so the
|
|
78
|
+
* caller can choose to degrade or retry. */
|
|
79
|
+
async refresh() {
|
|
80
|
+
if (!this.client)
|
|
81
|
+
throw new Error(`upstream ${this.name} not connected`);
|
|
82
|
+
const result = await this.client.listTools({});
|
|
83
|
+
this.toolsCache = (result.tools ?? []).map((t) => ({
|
|
84
|
+
namespacedName: `${this.namespacePrefix}.${t.name}`,
|
|
85
|
+
upstreamName: t.name,
|
|
86
|
+
sourceName: this.name,
|
|
87
|
+
description: t.description ?? "",
|
|
88
|
+
inputSchema: t.inputSchema,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
/** Forward a callTool request to the upstream by upstream-tool name
|
|
92
|
+
* (NOT the namespaced name — the registry strips the prefix before
|
|
93
|
+
* calling here). */
|
|
94
|
+
async callTool(upstreamName, args) {
|
|
95
|
+
if (!this.client) {
|
|
96
|
+
throw new Error(`upstream ${this.name} is ${this.status}`);
|
|
97
|
+
}
|
|
98
|
+
return this.client.callTool({
|
|
99
|
+
name: upstreamName,
|
|
100
|
+
arguments: (args ?? {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async close() {
|
|
104
|
+
if (this.refreshTimer)
|
|
105
|
+
clearInterval(this.refreshTimer);
|
|
106
|
+
try {
|
|
107
|
+
await this.client?.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* socket may already be down */
|
|
111
|
+
}
|
|
112
|
+
this.status = "disconnected";
|
|
113
|
+
}
|
|
114
|
+
}
|