@thotischner/observability-mcp 1.8.1 → 3.0.1

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.
Files changed (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
package/dist/context.d.ts CHANGED
@@ -27,6 +27,12 @@ export interface RequestContext {
27
27
  * "default" for anonymous principals + missing-tenant credentials,
28
28
  * preserving the single-namespace behaviour of pre-E7 deployments. */
29
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[];
30
36
  /** Correlates all tool calls within one transport request/session. */
31
37
  correlationId: string;
32
38
  }
@@ -36,4 +42,30 @@ export declare function defaultContext(): RequestContext;
36
42
  export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
37
43
  allowBypassRedaction?: boolean;
38
44
  tenant?: string;
45
+ allowedTools?: string[];
39
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
@@ -17,6 +17,41 @@ export function principalContext(principalId, allowedSources, opts = {}) {
17
17
  allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
18
18
  allowBypassRedaction: opts.allowBypassRedaction || undefined,
19
19
  tenant: normaliseTenant(opts.tenant),
20
+ allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
20
21
  correlationId: randomUUID(),
21
22
  };
22
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,54 @@
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
+ * "a=https://gw.a/mcp,b=stdio:/usr/bin/mcp arg1,c=wss://gw.c/mcp/ws"
22
+ *
23
+ * Transport selection:
24
+ * - `https?://` → HTTP (Streamable). Bearer token from
25
+ * OMCP_FEDERATION_TOKEN_<UPPERCASE-NAME>.
26
+ * - `ws://`/`wss://` → WebSocket. No bearer header (the SDK
27
+ * transport only accepts a URL); embed auth
28
+ * in the URL or front the gateway with a
29
+ * proxy.
30
+ * - `stdio:<cmd>` → spawn a child process; `\` escapes spaces
31
+ * in the command/argv list.
32
+ *
33
+ * Tokens never appear in the URL list itself for HTTP — kept
34
+ * separate so they don't leak into logs / audit entries.
35
+ */
36
+ export interface ParsedUpstreamHttp {
37
+ kind: "http";
38
+ name: string;
39
+ url: string;
40
+ bearerToken?: string;
41
+ }
42
+ export interface ParsedUpstreamStdio {
43
+ kind: "stdio";
44
+ name: string;
45
+ command: string;
46
+ args: string[];
47
+ }
48
+ export interface ParsedUpstreamWebsocket {
49
+ kind: "ws";
50
+ name: string;
51
+ url: string;
52
+ }
53
+ export type ParsedUpstream = ParsedUpstreamHttp | ParsedUpstreamStdio | ParsedUpstreamWebsocket;
54
+ export declare function parseFederationEnv(env?: NodeJS.ProcessEnv): ParsedUpstream[];
@@ -0,0 +1,122 @@
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
+ /** Split a "command arg1 arg2" string honouring backslash escapes
49
+ * so an operator can embed a literal space with `\ `. Nothing
50
+ * fancier — we explicitly don't run a shell, so quoting wouldn't
51
+ * apply uniformly. */
52
+ function splitCommand(spec) {
53
+ const tokens = [];
54
+ let cur = "";
55
+ let esc = false;
56
+ for (const ch of spec) {
57
+ if (esc) {
58
+ cur += ch;
59
+ esc = false;
60
+ continue;
61
+ }
62
+ if (ch === "\\") {
63
+ esc = true;
64
+ continue;
65
+ }
66
+ if (ch === " " || ch === "\t") {
67
+ if (cur) {
68
+ tokens.push(cur);
69
+ cur = "";
70
+ }
71
+ continue;
72
+ }
73
+ cur += ch;
74
+ }
75
+ if (cur)
76
+ tokens.push(cur);
77
+ const [command = "", ...args] = tokens;
78
+ return { command, args };
79
+ }
80
+ export function parseFederationEnv(env = process.env) {
81
+ const raw = env.OMCP_FEDERATION_UPSTREAMS?.trim();
82
+ if (!raw)
83
+ return [];
84
+ const entries = [];
85
+ for (const part of raw.split(",")) {
86
+ const trimmed = part.trim();
87
+ if (!trimmed)
88
+ continue;
89
+ const eq = trimmed.indexOf("=");
90
+ if (eq < 0) {
91
+ console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${trimmed}" missing "=" — skipping`);
92
+ continue;
93
+ }
94
+ const name = trimmed.slice(0, eq).trim();
95
+ const spec = trimmed.slice(eq + 1).trim();
96
+ if (!/^[a-z][a-z0-9_-]*$/i.test(name)) {
97
+ console.warn(`OMCP_FEDERATION_UPSTREAMS entry name "${name}" is invalid — skipping`);
98
+ continue;
99
+ }
100
+ if (spec.startsWith("stdio:")) {
101
+ const { command, args } = splitCommand(spec.slice("stdio:".length).trim());
102
+ if (!command) {
103
+ console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${name}" stdio: missing command — skipping`);
104
+ continue;
105
+ }
106
+ entries.push({ kind: "stdio", name, command, args });
107
+ continue;
108
+ }
109
+ if (/^wss?:\/\//.test(spec)) {
110
+ entries.push({ kind: "ws", name, url: spec });
111
+ continue;
112
+ }
113
+ if (!/^https?:\/\//.test(spec)) {
114
+ console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${name}" url "${spec}" must start with http://, https://, ws://, wss:// (or stdio:) — skipping`);
115
+ continue;
116
+ }
117
+ const tokenEnv = `OMCP_FEDERATION_TOKEN_${name.toUpperCase().replace(/[-.]/g, "_")}`;
118
+ const bearerToken = env[tokenEnv]?.trim() || undefined;
119
+ entries.push({ kind: "http", name, url: spec, bearerToken });
120
+ }
121
+ return entries;
122
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
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
+ { kind: "http", name: "a", url: "https://gw.a/mcp", bearerToken: undefined },
108
+ { kind: "http", 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]?.kind, "http");
117
+ if (parsed[0]?.kind === "http") {
118
+ assert.equal(parsed[0].bearerToken, "secret-token-xyz");
119
+ }
120
+ });
121
+ test("parseFederationEnv: stdio:<command> entries parse with kind=stdio", () => {
122
+ const parsed = parseFederationEnv({
123
+ OMCP_FEDERATION_UPSTREAMS: "local=stdio:/usr/local/bin/mcp",
124
+ });
125
+ assert.equal(parsed.length, 1);
126
+ assert.deepEqual(parsed[0], {
127
+ kind: "stdio",
128
+ name: "local",
129
+ command: "/usr/local/bin/mcp",
130
+ args: [],
131
+ });
132
+ });
133
+ test("parseFederationEnv: stdio command args split on whitespace", () => {
134
+ const parsed = parseFederationEnv({
135
+ OMCP_FEDERATION_UPSTREAMS: "weather=stdio:node weather-mcp.js --port 0",
136
+ });
137
+ assert.equal(parsed[0]?.kind, "stdio");
138
+ if (parsed[0]?.kind === "stdio") {
139
+ assert.equal(parsed[0].command, "node");
140
+ assert.deepEqual(parsed[0].args, ["weather-mcp.js", "--port", "0"]);
141
+ }
142
+ });
143
+ test("parseFederationEnv: backslash-escapes preserve spaces in stdio commands", () => {
144
+ const parsed = parseFederationEnv({
145
+ OMCP_FEDERATION_UPSTREAMS: "x=stdio:/opt/path\\ with\\ spaces/mcp arg1",
146
+ });
147
+ assert.equal(parsed[0]?.kind, "stdio");
148
+ if (parsed[0]?.kind === "stdio") {
149
+ assert.equal(parsed[0].command, "/opt/path with spaces/mcp");
150
+ assert.deepEqual(parsed[0].args, ["arg1"]);
151
+ }
152
+ });
153
+ test("parseFederationEnv: stdio with no command after stdio: is skipped", () => {
154
+ const parsed = parseFederationEnv({
155
+ OMCP_FEDERATION_UPSTREAMS: "broken=stdio:",
156
+ });
157
+ assert.equal(parsed.length, 0);
158
+ });
159
+ test("parseFederationEnv: http + stdio entries co-exist", () => {
160
+ const parsed = parseFederationEnv({
161
+ OMCP_FEDERATION_UPSTREAMS: "remote=https://gw/mcp,local=stdio:mcp",
162
+ });
163
+ assert.equal(parsed.length, 2);
164
+ assert.equal(parsed[0]?.kind, "http");
165
+ assert.equal(parsed[1]?.kind, "stdio");
166
+ });
167
+ test("parseFederationEnv: ws:// + wss:// entries parse with kind=ws", () => {
168
+ const parsed = parseFederationEnv({
169
+ OMCP_FEDERATION_UPSTREAMS: "plain=ws://gw/mcp/ws,secure=wss://gw/mcp/ws",
170
+ });
171
+ assert.equal(parsed.length, 2);
172
+ assert.deepEqual(parsed[0], { kind: "ws", name: "plain", url: "ws://gw/mcp/ws" });
173
+ assert.deepEqual(parsed[1], { kind: "ws", name: "secure", url: "wss://gw/mcp/ws" });
174
+ });
175
+ test("parseFederationEnv: ws upstreams do NOT carry bearer tokens (URL-only)", () => {
176
+ // Even when a matching OMCP_FEDERATION_TOKEN_X is set, the ws entry
177
+ // shouldn't grow a bearerToken field — the SDK transport only
178
+ // accepts the URL.
179
+ const parsed = parseFederationEnv({
180
+ OMCP_FEDERATION_UPSTREAMS: "x=wss://gw/mcp/ws",
181
+ OMCP_FEDERATION_TOKEN_X: "would-be-ignored",
182
+ });
183
+ assert.equal(parsed[0]?.kind, "ws");
184
+ // The ws branch has no `bearerToken` property at all.
185
+ assert.equal(parsed[0].bearerToken, undefined);
186
+ });
187
+ test("parseFederationEnv: all four transport variants co-exist", () => {
188
+ const parsed = parseFederationEnv({
189
+ OMCP_FEDERATION_UPSTREAMS: "a=https://gw/mcp,b=http://gw/mcp,c=ws://gw/mcp/ws,d=stdio:/bin/mcp",
190
+ });
191
+ assert.equal(parsed.length, 4);
192
+ assert.deepEqual(parsed.map((p) => p.kind), ["http", "http", "ws", "stdio"]);
193
+ });
194
+ test("parseFederationEnv: skips malformed entries with a warning, keeps the rest", () => {
195
+ const parsed = parseFederationEnv({
196
+ OMCP_FEDERATION_UPSTREAMS: "good=https://gw/mcp,no-equals,bad-url=ftp://x,a=https://b/mcp",
197
+ });
198
+ // Only `good=` and `a=` survive
199
+ assert.deepEqual(parsed.map((p) => p.name), ["good", "a"]);
200
+ });
201
+ test("parseFederationEnv: rejects invalid names (must start with letter)", () => {
202
+ const parsed = parseFederationEnv({
203
+ OMCP_FEDERATION_UPSTREAMS: "1bad=https://x/mcp,_also=https://y/mcp,ok=https://z/mcp",
204
+ });
205
+ assert.deepEqual(parsed.map((p) => p.name), ["ok"]);
206
+ });
@@ -0,0 +1,86 @@
1
+ interface UpstreamCommonConfig {
2
+ /** Stable source name (used in the namespace prefix + audit entries). */
3
+ name: string;
4
+ /** Tool-name prefix; default = source name. Resulting registered
5
+ * tool name is `<prefix>.<upstream-tool-name>`. */
6
+ namespacePrefix?: string;
7
+ /** ms between automatic catalog refreshes. Default 5 minutes;
8
+ * 0 disables auto-refresh (manual refresh() only). */
9
+ refreshIntervalMs?: number;
10
+ /** Test-only: inject a pre-built MCP Transport instance.
11
+ * Skips the spawn / fetch path entirely. */
12
+ _transport?: unknown;
13
+ }
14
+ export interface UpstreamHttpConfig extends UpstreamCommonConfig {
15
+ transport?: "http";
16
+ /** Upstream Streamable HTTP URL (must end at /mcp). */
17
+ url: string;
18
+ /** Static bearer token sent on every outbound call. */
19
+ bearerToken?: string;
20
+ }
21
+ export interface UpstreamStdioConfig extends UpstreamCommonConfig {
22
+ transport: "stdio";
23
+ /** Executable to spawn (e.g. "npx", "node", "/usr/local/bin/mcp"). */
24
+ command: string;
25
+ /** Argv for the executable. */
26
+ args?: string[];
27
+ /** Extra env merged into the child process's environment. */
28
+ env?: Record<string, string>;
29
+ }
30
+ export interface UpstreamWebsocketConfig extends UpstreamCommonConfig {
31
+ transport: "ws";
32
+ /** Upstream WebSocket URL — `ws://` or `wss://`. */
33
+ url: string;
34
+ }
35
+ export type UpstreamConfig = UpstreamHttpConfig | UpstreamStdioConfig | UpstreamWebsocketConfig;
36
+ export interface UpstreamToolInfo {
37
+ /** Local namespaced name: `<prefix>.<upstreamName>`. */
38
+ namespacedName: string;
39
+ /** Original name on the upstream. */
40
+ upstreamName: string;
41
+ /** Upstream source name (audit attribution + diagnostics). */
42
+ sourceName: string;
43
+ /** Tool description as the upstream advertises it. */
44
+ description: string;
45
+ /** Upstream's inputSchema, forwarded verbatim. */
46
+ inputSchema: unknown;
47
+ }
48
+ export type UpstreamStatus = "connecting" | "ready" | "degraded" | "disconnected";
49
+ export declare class UpstreamClient {
50
+ readonly name: string;
51
+ /** Empty-string for stdio (no remote URL); kept on the public surface
52
+ * so the UI doesn't have to special-case the transport kind. */
53
+ readonly url: string;
54
+ readonly namespacePrefix: string;
55
+ readonly transportKind: "http" | "stdio" | "ws";
56
+ private cfg;
57
+ private client?;
58
+ private transport?;
59
+ private toolsCache;
60
+ private status;
61
+ private lastError?;
62
+ private refreshTimer?;
63
+ private refreshIntervalMs;
64
+ constructor(cfg: UpstreamConfig);
65
+ getStatus(): {
66
+ status: UpstreamStatus;
67
+ lastError?: string;
68
+ toolCount: number;
69
+ };
70
+ /** Cached catalog (read-only). */
71
+ getTools(): UpstreamToolInfo[];
72
+ /** Connect + initial catalog fetch. Logs failures and leaves the
73
+ * client in `degraded` so the catalog stays empty rather than
74
+ * blocking startup. Re-runnable. */
75
+ connect(): Promise<void>;
76
+ /** Re-fetch the upstream tool catalog. Throws on failure so the
77
+ * caller can choose to degrade or retry. */
78
+ refresh(): Promise<void>;
79
+ /** Forward a callTool request to the upstream by upstream-tool name
80
+ * (NOT the namespaced name — the registry strips the prefix before
81
+ * calling here). */
82
+ callTool(upstreamName: string, args: unknown): Promise<unknown>;
83
+ close(): Promise<void>;
84
+ private buildTransport;
85
+ }
86
+ export {};