@thotischner/observability-mcp 1.8.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +9 -0
- package/dist/audit/log.js +20 -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 +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -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 +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- 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/endpoints.js +44 -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 +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -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 +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -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 +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -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 +32 -0
- package/dist/context.js +35 -0
- 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 +1188 -120
- 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/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 +215 -7
- package/dist/openapi.test.js +34 -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 +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- 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 +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -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/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 +1729 -100
- package/package.json +13 -3
|
@@ -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 {};
|
|
@@ -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
|
+
}
|