@thotischner/observability-mcp 3.0.0 → 3.1.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 +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +522 -67
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +898 -116
- 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
|
-
* "
|
|
21
|
+
* "a=https://gw.a/mcp,b=stdio:/usr/bin/mcp arg1,c=wss://gw.c/mcp/ws"
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
69
|
-
|
|
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]?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {};
|