@thotischner/observability-mcp 3.0.0 → 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 (53) hide show
  1. package/dist/audit/sinks/s3.d.ts +61 -0
  2. package/dist/audit/sinks/s3.js +179 -0
  3. package/dist/audit/sinks/s3.test.d.ts +1 -0
  4. package/dist/audit/sinks/s3.test.js +175 -0
  5. package/dist/auth/policy/batch-dry-run.js +15 -0
  6. package/dist/connectors/loader.d.ts +8 -0
  7. package/dist/connectors/loader.js +49 -0
  8. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  9. package/dist/connectors/manifest-hooks.test.js +206 -0
  10. package/dist/federation/registry.d.ts +27 -5
  11. package/dist/federation/registry.js +49 -4
  12. package/dist/federation/registry.test.js +79 -3
  13. package/dist/federation/upstream.d.ts +32 -6
  14. package/dist/federation/upstream.js +60 -12
  15. package/dist/federation/upstream.test.d.ts +1 -0
  16. package/dist/federation/upstream.test.js +118 -0
  17. package/dist/index.js +306 -65
  18. package/dist/metrics/self.d.ts +1 -0
  19. package/dist/metrics/self.js +8 -0
  20. package/dist/policy/redact.js +1 -1
  21. package/dist/postmortem/store.d.ts +34 -0
  22. package/dist/postmortem/store.js +113 -0
  23. package/dist/postmortem/store.test.d.ts +1 -0
  24. package/dist/postmortem/store.test.js +118 -0
  25. package/dist/scim/compliance.test.d.ts +1 -0
  26. package/dist/scim/compliance.test.js +169 -0
  27. package/dist/scim/factory.test.d.ts +1 -0
  28. package/dist/scim/factory.test.js +54 -0
  29. package/dist/scim/patch-ops.test.d.ts +1 -0
  30. package/dist/scim/patch-ops.test.js +100 -0
  31. package/dist/scim/redis-store.d.ts +38 -0
  32. package/dist/scim/redis-store.js +178 -0
  33. package/dist/scim/redis-store.test.d.ts +1 -0
  34. package/dist/scim/redis-store.test.js +138 -0
  35. package/dist/scim/routes.d.ts +27 -2
  36. package/dist/scim/routes.js +161 -15
  37. package/dist/scim/store.d.ts +40 -1
  38. package/dist/scim/store.js +23 -5
  39. package/dist/sdk/hook-wrappers.d.ts +39 -0
  40. package/dist/sdk/hook-wrappers.js +113 -0
  41. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  42. package/dist/sdk/hook-wrappers.test.js +204 -0
  43. package/dist/sdk/index.d.ts +13 -0
  44. package/dist/tools/detect-anomalies.d.ts +12 -1
  45. package/dist/tools/detect-anomalies.js +22 -2
  46. package/dist/tools/topology.js +23 -5
  47. package/dist/tools/topology.test.js +45 -0
  48. package/dist/transport/transportSessionMap.d.ts +70 -0
  49. package/dist/transport/transportSessionMap.js +128 -0
  50. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  51. package/dist/transport/transportSessionMap.test.js +111 -0
  52. package/dist/ui/index.html +856 -101
  53. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ // Manifest-driven hook auto-registration (Q10).
2
+ //
3
+ // Stages a synthetic plugin directory with:
4
+ // - package.json + manifest.json declaring hooks[]
5
+ // - index.js exporting a no-op connector factory
6
+ // - hooks/<kind>.js modules exporting handler defaults
7
+ // Runs PluginLoader (with VERIFY_PLUGINS off — we test the
8
+ // hook wiring, not the trust-root path) and asserts the
9
+ // HookRegistry now has the entries the manifest declared.
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { PluginLoader } from "./loader.js";
16
+ import { HookRegistry } from "../sdk/hooks.js";
17
+ function stagePlugin(opts) {
18
+ const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-hooks-"));
19
+ const root = join(stage, opts.name);
20
+ mkdirSync(root, { recursive: true });
21
+ writeFileSync(join(root, "package.json"), JSON.stringify({
22
+ name: `@observability-mcp/connector-${opts.name}`,
23
+ observabilityMcp: { kind: "connector", name: opts.name, manifest: "./manifest.json" },
24
+ main: "./index.js",
25
+ }));
26
+ writeFileSync(join(root, "manifest.json"), JSON.stringify({
27
+ schemaVersion: 1,
28
+ name: opts.name,
29
+ displayName: opts.name,
30
+ version: "1.0.0",
31
+ description: "test plugin",
32
+ signalTypes: ["topology"],
33
+ capabilities: { listServices: true },
34
+ compat: { serverVersion: ">=3.0.0" },
35
+ hooks: opts.hooks?.map((h) => ({
36
+ kind: h.kind,
37
+ module: h.module,
38
+ priority: h.priority,
39
+ mode: h.mode,
40
+ })),
41
+ }));
42
+ // Tiny no-op connector factory.
43
+ writeFileSync(join(root, "index.js"), opts.indexJs ??
44
+ `export default function create() {
45
+ return {
46
+ type: "${opts.name}",
47
+ signalType: "topology",
48
+ name: "${opts.name}",
49
+ async connect() {},
50
+ async healthCheck() { return { status: "down", latencyMs: 0 }; },
51
+ async disconnect() {},
52
+ getDefaultMetrics() { return []; },
53
+ getMetrics() { return []; },
54
+ async listServices() { return []; },
55
+ };
56
+ }`);
57
+ // Hook modules — each writes a marker into the global for assertions.
58
+ for (const h of opts.hooks ?? []) {
59
+ const hookPath = join(root, h.module);
60
+ mkdirSync(join(hookPath, ".."), { recursive: true });
61
+ writeFileSync(hookPath, h.body ??
62
+ `export default async function handler(ctx, payload) {
63
+ globalThis.__omcp_test_hook_calls = (globalThis.__omcp_test_hook_calls ?? []);
64
+ globalThis.__omcp_test_hook_calls.push({ plugin: ctx.principal, kind: ctx.kind, target: ctx.target });
65
+ return { allow: true, payload };
66
+ }`);
67
+ }
68
+ return stage;
69
+ }
70
+ test("PluginLoader: manifest hooks auto-register on plugin load", async () => {
71
+ const stage = stagePlugin({
72
+ name: "alpha",
73
+ hooks: [
74
+ { kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 50 },
75
+ { kind: "tool_post_invoke", module: "hooks/post.mjs" },
76
+ ],
77
+ });
78
+ const registry = new HookRegistry();
79
+ const loader = new PluginLoader({
80
+ pluginsDir: stage,
81
+ verify: false,
82
+ hookRegistry: registry,
83
+ });
84
+ await loader.load();
85
+ const pre = registry.list("tool_pre_invoke");
86
+ const post = registry.list("tool_post_invoke");
87
+ assert.equal(pre.length, 1);
88
+ assert.equal(pre[0].pluginName, "alpha");
89
+ assert.equal(pre[0].priority, 50);
90
+ assert.equal(post.length, 1);
91
+ assert.equal(post[0].pluginName, "alpha");
92
+ assert.equal(post[0].priority, 100); // default
93
+ });
94
+ test("PluginLoader: hook handlers fire end-to-end through HookRegistry", async () => {
95
+ delete globalThis.__omcp_test_hook_calls;
96
+ const stage = stagePlugin({
97
+ name: "beta",
98
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
99
+ });
100
+ const registry = new HookRegistry();
101
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
102
+ await loader.load();
103
+ const result = await registry.fire("tool_pre_invoke", { principal: "alice", tenant: "default", kind: "tool_pre_invoke", target: "tool.x" }, { args: { foo: 1 } });
104
+ assert.equal(result.allow, true);
105
+ const calls = globalThis.__omcp_test_hook_calls;
106
+ assert.ok(calls && calls.length === 1, "hook should have fired once");
107
+ });
108
+ test("PluginLoader: missing hook module is skipped with a warning, others still register", async () => {
109
+ // Stage one good hook normally, then rewrite the manifest to ALSO
110
+ // reference a sibling module path that doesn't exist on disk. The
111
+ // loader must skip the missing one (existsSync branch) and still
112
+ // register the good one.
113
+ const stage = stagePlugin({
114
+ name: "gamma",
115
+ hooks: [{ kind: "tool_post_invoke", module: "hooks/post.mjs" }],
116
+ });
117
+ const manifestPath = join(stage, "gamma", "manifest.json");
118
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
119
+ raw.hooks = [
120
+ { kind: "tool_pre_invoke", module: "hooks/genuinely-missing.mjs" },
121
+ { kind: "tool_post_invoke", module: "hooks/post.mjs" },
122
+ ];
123
+ writeFileSync(manifestPath, JSON.stringify(raw));
124
+ const registry = new HookRegistry();
125
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
126
+ // The missing pre hook was skipped; the good post hook registered.
127
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
128
+ assert.equal(registry.list("tool_post_invoke").length, 1);
129
+ });
130
+ test("PluginLoader: hook module path that escapes the plugin root is rejected", async () => {
131
+ // Stage a plugin whose manifest tries to reference a path with `..`
132
+ // — the loader must refuse to import it.
133
+ const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-escape-"));
134
+ const root = join(stage, "evil");
135
+ mkdirSync(root, { recursive: true });
136
+ writeFileSync(join(root, "package.json"), JSON.stringify({
137
+ name: "@observability-mcp/connector-evil",
138
+ observabilityMcp: { kind: "connector", name: "evil", manifest: "./manifest.json" },
139
+ main: "./index.js",
140
+ }));
141
+ writeFileSync(join(root, "manifest.json"), JSON.stringify({
142
+ schemaVersion: 1,
143
+ name: "evil",
144
+ displayName: "evil",
145
+ version: "1.0.0",
146
+ description: "x",
147
+ signalTypes: ["topology"],
148
+ capabilities: { listServices: true },
149
+ hooks: [{ kind: "tool_pre_invoke", module: "../escape.mjs" }],
150
+ }));
151
+ writeFileSync(join(root, "index.js"), `export default function create() {
152
+ return {
153
+ type: "evil", signalType: "topology", name: "evil",
154
+ async connect() {},
155
+ async healthCheck() { return { status: "down", latencyMs: 0 }; },
156
+ async disconnect() {},
157
+ getDefaultMetrics() { return []; },
158
+ getMetrics() { return []; },
159
+ async listServices() { return []; },
160
+ };
161
+ }`);
162
+ // Stage a file outside the plugin root the manifest references.
163
+ writeFileSync(join(stage, "escape.mjs"), `export default async () => ({ allow: false, reason: "should never run" });`);
164
+ const registry = new HookRegistry();
165
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
166
+ await loader.load();
167
+ // The escape hook was refused; nothing in the registry.
168
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
169
+ });
170
+ test("PluginLoader: hot-reload — re-loading replaces prior hook registrations", async () => {
171
+ const stage = stagePlugin({
172
+ name: "delta",
173
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 10 }],
174
+ });
175
+ const registry = new HookRegistry();
176
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
177
+ assert.equal(registry.list("tool_pre_invoke").length, 1);
178
+ // Re-load (same stage, same plugin) — registry should still hold
179
+ // exactly one entry for tool_pre_invoke owned by delta. The loader
180
+ // calls unregisterPlugin first, then re-registers.
181
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
182
+ const after = registry.list("tool_pre_invoke");
183
+ assert.equal(after.length, 1);
184
+ assert.equal(after[0].pluginName, "delta");
185
+ });
186
+ test("PluginLoader: no hookRegistry passed → hooks are silently ignored (back-compat)", async () => {
187
+ const stage = stagePlugin({
188
+ name: "epsilon",
189
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
190
+ });
191
+ // No hookRegistry — load() must not throw.
192
+ const loader = new PluginLoader({ pluginsDir: stage, verify: false });
193
+ await loader.load();
194
+ assert.ok(loader.has("epsilon"));
195
+ });
196
+ test("PluginLoader: hook with no default export is skipped", async () => {
197
+ const stage = stagePlugin({
198
+ name: "zeta",
199
+ hooks: [{ kind: "tool_pre_invoke", module: "hooks/noexport.mjs" }],
200
+ });
201
+ // Overwrite the noexport.mjs file to remove default export.
202
+ writeFileSync(join(stage, "zeta", "hooks", "noexport.mjs"), "export const meta = 'no handler here';");
203
+ const registry = new HookRegistry();
204
+ await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
205
+ assert.equal(registry.list("tool_pre_invoke").length, 0);
206
+ });
@@ -18,15 +18,37 @@ export declare class FederationRegistry {
18
18
  * Parse the OMCP_FEDERATION_UPSTREAMS env into a list of upstream
19
19
  * configs. Shape:
20
20
  *
21
- * "name1=https://gw.a/mcp,name2=https://gw.b/mcp"
21
+ * "a=https://gw.a/mcp,b=stdio:/usr/bin/mcp arg1,c=wss://gw.c/mcp/ws"
22
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.
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.
26
35
  */
27
- export interface ParsedUpstream {
36
+ export interface ParsedUpstreamHttp {
37
+ kind: "http";
28
38
  name: string;
29
39
  url: string;
30
40
  bearerToken?: string;
31
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;
32
54
  export declare function parseFederationEnv(env?: NodeJS.ProcessEnv): ParsedUpstream[];
@@ -45,6 +45,38 @@ export class FederationRegistry {
45
45
  this.upstreams.clear();
46
46
  }
47
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
+ }
48
80
  export function parseFederationEnv(env = process.env) {
49
81
  const raw = env.OMCP_FEDERATION_UPSTREAMS?.trim();
50
82
  if (!raw)
@@ -60,18 +92,31 @@ export function parseFederationEnv(env = process.env) {
60
92
  continue;
61
93
  }
62
94
  const name = trimmed.slice(0, eq).trim();
63
- const url = trimmed.slice(eq + 1).trim();
95
+ const spec = trimmed.slice(eq + 1).trim();
64
96
  if (!/^[a-z][a-z0-9_-]*$/i.test(name)) {
65
97
  console.warn(`OMCP_FEDERATION_UPSTREAMS entry name "${name}" is invalid — skipping`);
66
98
  continue;
67
99
  }
68
- if (!/^https?:\/\//.test(url)) {
69
- console.warn(`OMCP_FEDERATION_UPSTREAMS entry "${name}" url "${url}" must start with http:// or https:// — skipping`);
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`);
70
115
  continue;
71
116
  }
72
117
  const tokenEnv = `OMCP_FEDERATION_TOKEN_${name.toUpperCase().replace(/[-.]/g, "_")}`;
73
118
  const bearerToken = env[tokenEnv]?.trim() || undefined;
74
- entries.push({ name, url, bearerToken });
119
+ entries.push({ kind: "http", name, url: spec, bearerToken });
75
120
  }
76
121
  return entries;
77
122
  }
@@ -104,8 +104,8 @@ test("parseFederationEnv: parses name=url comma-separated entries", () => {
104
104
  OMCP_FEDERATION_UPSTREAMS: "a=https://gw.a/mcp,b=https://gw.b/mcp",
105
105
  });
106
106
  assert.deepEqual(parsed, [
107
- { name: "a", url: "https://gw.a/mcp", bearerToken: undefined },
108
- { name: "b", url: "https://gw.b/mcp", bearerToken: undefined },
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
109
  ]);
110
110
  });
111
111
  test("parseFederationEnv: picks up bearer token per OMCP_FEDERATION_TOKEN_<NAME>", () => {
@@ -113,7 +113,83 @@ test("parseFederationEnv: picks up bearer token per OMCP_FEDERATION_TOKEN_<NAME>
113
113
  OMCP_FEDERATION_UPSTREAMS: "prod=https://gw.prod/mcp",
114
114
  OMCP_FEDERATION_TOKEN_PROD: "secret-token-xyz",
115
115
  });
116
- assert.equal(parsed[0]?.bearerToken, "secret-token-xyz");
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"]);
117
193
  });
118
194
  test("parseFederationEnv: skips malformed entries with a warning, keeps the rest", () => {
119
195
  const parsed = parseFederationEnv({
@@ -1,17 +1,38 @@
1
- export interface UpstreamConfig {
1
+ interface UpstreamCommonConfig {
2
2
  /** Stable source name (used in the namespace prefix + audit entries). */
3
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
4
  /** Tool-name prefix; default = source name. Resulting registered
9
5
  * tool name is `<prefix>.<upstream-tool-name>`. */
10
6
  namespacePrefix?: string;
11
7
  /** ms between automatic catalog refreshes. Default 5 minutes;
12
8
  * 0 disables auto-refresh (manual refresh() only). */
13
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;
14
34
  }
35
+ export type UpstreamConfig = UpstreamHttpConfig | UpstreamStdioConfig | UpstreamWebsocketConfig;
15
36
  export interface UpstreamToolInfo {
16
37
  /** Local namespaced name: `<prefix>.<upstreamName>`. */
17
38
  namespacedName: string;
@@ -27,9 +48,12 @@ export interface UpstreamToolInfo {
27
48
  export type UpstreamStatus = "connecting" | "ready" | "degraded" | "disconnected";
28
49
  export declare class UpstreamClient {
29
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. */
30
53
  readonly url: string;
31
54
  readonly namespacePrefix: string;
32
- private bearerToken?;
55
+ readonly transportKind: "http" | "stdio" | "ws";
56
+ private cfg;
33
57
  private client?;
34
58
  private transport?;
35
59
  private toolsCache;
@@ -57,4 +81,6 @@ export declare class UpstreamClient {
57
81
  * calling here). */
58
82
  callTool(upstreamName: string, args: unknown): Promise<unknown>;
59
83
  close(): Promise<void>;
84
+ private buildTransport;
60
85
  }
86
+ export {};
@@ -5,24 +5,42 @@
5
5
  // locally. callTool() forwards a request to the upstream and returns
6
6
  // its CallToolResult verbatim.
7
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).
8
+ // Transports:
9
+ // - "http" — Streamable HTTP to an upstream gateway URL (default).
10
+ // - "stdio" spawn a child process that speaks MCP over its stdio
11
+ // channels. The classic MCP transport, useful when the
12
+ // upstream is a CLI-style server (omcp inspector-config,
13
+ // a local-only MCP, an in-cluster sidecar).
14
+ // - "ws" — WebSocket to a ws:// or wss:// URL. Useful when the
15
+ // upstream gateway exposes MCP via the WS subprotocol
16
+ // rather than streamable HTTP. No bearer-auth header
17
+ // (the SDK transport only accepts a URL); operators
18
+ // that need auth append it as a query string or run
19
+ // the gateway behind an authenticating reverse proxy.
11
20
  //
12
21
  // Auth forwarding modes:
13
22
  // - "none" — no auth header on outbound calls
14
23
  // - "bearer" — static OMCP_FEDERATION_TOKEN_<NAME> sent as Bearer
24
+ // (HTTP transport only — stdio doesn't have HTTP headers)
15
25
  //
16
26
  // OIDC + UAID passthrough are deferred — they require a per-request
17
27
  // identity hand-off the federation manager doesn't carry today.
18
28
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
29
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
30
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
31
+ import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
20
32
  export class UpstreamClient {
21
33
  name;
34
+ /** Empty-string for stdio (no remote URL); kept on the public surface
35
+ * so the UI doesn't have to special-case the transport kind. */
22
36
  url;
23
37
  namespacePrefix;
24
- bearerToken;
38
+ transportKind;
39
+ cfg;
25
40
  client;
41
+ // `unknown` because the SDK exposes a different concrete type per
42
+ // transport. Federation only talks to the transport via the SDK
43
+ // Client object, so the concrete type isn't needed here.
26
44
  transport;
27
45
  toolsCache = [];
28
46
  status = "disconnected";
@@ -30,10 +48,17 @@ export class UpstreamClient {
30
48
  refreshTimer;
31
49
  refreshIntervalMs;
32
50
  constructor(cfg) {
51
+ this.cfg = cfg;
33
52
  this.name = cfg.name;
34
- this.url = cfg.url;
53
+ this.transportKind =
54
+ cfg.transport === "stdio" ? "stdio" :
55
+ cfg.transport === "ws" ? "ws" :
56
+ "http";
57
+ this.url =
58
+ this.transportKind === "stdio" ? `stdio:${cfg.command}` :
59
+ this.transportKind === "ws" ? cfg.url :
60
+ cfg.url;
35
61
  this.namespacePrefix = cfg.namespacePrefix ?? cfg.name;
36
- this.bearerToken = cfg.bearerToken;
37
62
  this.refreshIntervalMs = cfg.refreshIntervalMs ?? 5 * 60 * 1000;
38
63
  }
39
64
  getStatus() {
@@ -49,13 +74,11 @@ export class UpstreamClient {
49
74
  async connect() {
50
75
  this.status = "connecting";
51
76
  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 });
77
+ this.transport = this.buildTransport();
58
78
  this.client = new Client({ name: "observability-mcp-federation", version: "1" }, { capabilities: {} });
79
+ // SDK Client.connect accepts any Transport implementation; the
80
+ // injected test transport just needs the start()/send()/close()
81
+ // contract — the type assertion sidesteps each concrete class.
59
82
  await this.client.connect(this.transport);
60
83
  await this.refresh();
61
84
  this.status = "ready";
@@ -111,4 +134,29 @@ export class UpstreamClient {
111
134
  }
112
135
  this.status = "disconnected";
113
136
  }
137
+ // --- internals ----------------------------------------------------
138
+ buildTransport() {
139
+ // Test path: a pre-built transport short-circuits the spawn / fetch.
140
+ if (this.cfg._transport)
141
+ return this.cfg._transport;
142
+ if (this.transportKind === "stdio") {
143
+ const cfg = this.cfg;
144
+ return new StdioClientTransport({
145
+ command: cfg.command,
146
+ args: cfg.args ?? [],
147
+ env: cfg.env,
148
+ });
149
+ }
150
+ if (this.transportKind === "ws") {
151
+ const cfg = this.cfg;
152
+ return new WebSocketClientTransport(new URL(cfg.url));
153
+ }
154
+ const cfg = this.cfg;
155
+ const url = new URL(cfg.url);
156
+ const init = { headers: {} };
157
+ if (cfg.bearerToken) {
158
+ init.headers["Authorization"] = `Bearer ${cfg.bearerToken}`;
159
+ }
160
+ return new StreamableHTTPClientTransport(url, { requestInit: init });
161
+ }
114
162
  }
@@ -0,0 +1 @@
1
+ export {};