@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,39 @@
1
+ import type { HookRegistry } from "./hooks.js";
2
+ export interface HookCtxBase {
3
+ /** Principal sub identifier from the caller's RequestContext. */
4
+ principal: string;
5
+ /** Tenant the caller is acting under. */
6
+ tenant: string;
7
+ /** Tool / resource / prompt target identifier. */
8
+ target: string;
9
+ }
10
+ type ToolHandler = (args: unknown, extra: unknown) => Promise<unknown> | unknown;
11
+ type ResourceHandler = (uri: URL | string, extra?: unknown) => Promise<unknown> | unknown;
12
+ type PromptHandler = (args: unknown, extra?: unknown) => Promise<unknown> | unknown;
13
+ /**
14
+ * Wrap a tool handler with `tool_pre_invoke` + `tool_post_invoke`
15
+ * hooks. Existing wire-up in index.ts is inlined; extracting it here
16
+ * for parity with the new resource + prompt wrappers and so tests
17
+ * can exercise the path without spinning up the full server.
18
+ */
19
+ export declare function wrapToolHandler(registry: HookRegistry, ctx: HookCtxBase, handler: ToolHandler): ToolHandler;
20
+ /**
21
+ * Wrap a resource readCallback with `resource_pre_fetch` +
22
+ * `resource_post_fetch` hooks.
23
+ *
24
+ * Pre-fetch sees `{uri}`; the payload's `uri` can be mutated (e.g. a
25
+ * canonicalising plugin) and the override flows into the original
26
+ * handler. Post-fetch sees `{uri, contents}`; the post-payload's
27
+ * `contents` (if set) replaces the response.
28
+ */
29
+ export declare function wrapResourceHandler(registry: HookRegistry, ctx: HookCtxBase, handler: ResourceHandler): ResourceHandler;
30
+ /**
31
+ * Wrap a prompt callback with `prompt_pre_fetch` + `prompt_post_fetch`
32
+ * hooks.
33
+ *
34
+ * Pre-fetch sees `{name, arguments}`; the override flows in. Post-fetch
35
+ * sees `{name, arguments, messages}`; the post-payload's `messages`
36
+ * (if set) replaces the response messages.
37
+ */
38
+ export declare function wrapPromptHandler(registry: HookRegistry, ctx: HookCtxBase, handler: PromptHandler): PromptHandler;
39
+ export {};
@@ -0,0 +1,113 @@
1
+ // Reusable hook-fire wrappers around the MCP SDK's tool / resource /
2
+ // prompt callbacks.
3
+ //
4
+ // Each wrapper fires the matching `*_pre_*` hook before the original
5
+ // handler runs and `*_post_*` after it returns. Hooks can:
6
+ // - deny the call (allow:false → caller sees a structured error)
7
+ // - mutate the payload before dispatch (args / uri / arguments)
8
+ // - mutate the result before it reaches the caller (contents /
9
+ // messages / tool result)
10
+ //
11
+ // When no hooks are registered (the default in the OSS demo) the
12
+ // wrappers are thin pass-throughs.
13
+ //
14
+ // The wrappers are pure — they take the HookRegistry + a ctx object
15
+ // and a handler, and return the wrapped handler. They never touch
16
+ // the McpServer SDK directly, so they're trivially unit-testable.
17
+ /** Shape an MCP tool dispatch returns on a hook denial. */
18
+ function deniedToolResult(reason) {
19
+ return {
20
+ content: [{ type: "text", text: reason ?? "denied by plugin hook" }],
21
+ isError: true,
22
+ };
23
+ }
24
+ /** Shape an MCP resource read returns on a hook denial. */
25
+ function deniedResourceResult(uri, reason) {
26
+ return {
27
+ contents: [
28
+ { uri, mimeType: "text/plain", text: reason ?? "denied by plugin hook" },
29
+ ],
30
+ isError: true,
31
+ };
32
+ }
33
+ /** Shape an MCP prompt fetch returns on a hook denial. */
34
+ function deniedPromptResult(reason) {
35
+ return {
36
+ description: reason ?? "denied by plugin hook",
37
+ messages: [],
38
+ isError: true,
39
+ };
40
+ }
41
+ /**
42
+ * Wrap a tool handler with `tool_pre_invoke` + `tool_post_invoke`
43
+ * hooks. Existing wire-up in index.ts is inlined; extracting it here
44
+ * for parity with the new resource + prompt wrappers and so tests
45
+ * can exercise the path without spinning up the full server.
46
+ */
47
+ export function wrapToolHandler(registry, ctx, handler) {
48
+ return async (args, extra) => {
49
+ const pre = await registry.fire("tool_pre_invoke", { ...ctx, kind: "tool_pre_invoke" }, { args });
50
+ if (!pre.allow)
51
+ return deniedToolResult(pre.reason);
52
+ const effectiveArgs = pre.payload?.args ?? args;
53
+ const result = await handler(effectiveArgs, extra);
54
+ const post = await registry.fire("tool_post_invoke", { ...ctx, kind: "tool_post_invoke" }, { args: effectiveArgs, result });
55
+ if (!post.allow)
56
+ return deniedToolResult(post.reason);
57
+ return post.payload?.result ?? result;
58
+ };
59
+ }
60
+ /**
61
+ * Wrap a resource readCallback with `resource_pre_fetch` +
62
+ * `resource_post_fetch` hooks.
63
+ *
64
+ * Pre-fetch sees `{uri}`; the payload's `uri` can be mutated (e.g. a
65
+ * canonicalising plugin) and the override flows into the original
66
+ * handler. Post-fetch sees `{uri, contents}`; the post-payload's
67
+ * `contents` (if set) replaces the response.
68
+ */
69
+ export function wrapResourceHandler(registry, ctx, handler) {
70
+ return async (uri, extra) => {
71
+ const uriStr = uri instanceof URL ? uri.toString() : String(uri);
72
+ const pre = await registry.fire("resource_pre_fetch", { ...ctx, kind: "resource_pre_fetch" }, { uri: uriStr });
73
+ if (!pre.allow)
74
+ return deniedResourceResult(uriStr, pre.reason);
75
+ const effectiveUri = pre.payload?.uri ?? uriStr;
76
+ // Preserve URL vs string typing the SDK expects.
77
+ const forwardedUri = uri instanceof URL && effectiveUri !== uriStr ? new URL(effectiveUri) : (uri instanceof URL ? uri : effectiveUri);
78
+ const result = await handler(forwardedUri, extra);
79
+ const post = await registry.fire("resource_post_fetch", { ...ctx, kind: "resource_post_fetch" }, { uri: effectiveUri, contents: result?.contents });
80
+ if (!post.allow)
81
+ return deniedResourceResult(effectiveUri, post.reason);
82
+ const overrideContents = post.payload?.contents;
83
+ if (overrideContents !== undefined && result && typeof result === "object") {
84
+ return { ...result, contents: overrideContents };
85
+ }
86
+ return result;
87
+ };
88
+ }
89
+ /**
90
+ * Wrap a prompt callback with `prompt_pre_fetch` + `prompt_post_fetch`
91
+ * hooks.
92
+ *
93
+ * Pre-fetch sees `{name, arguments}`; the override flows in. Post-fetch
94
+ * sees `{name, arguments, messages}`; the post-payload's `messages`
95
+ * (if set) replaces the response messages.
96
+ */
97
+ export function wrapPromptHandler(registry, ctx, handler) {
98
+ return async (args, extra) => {
99
+ const pre = await registry.fire("prompt_pre_fetch", { ...ctx, kind: "prompt_pre_fetch" }, { name: ctx.target, arguments: args });
100
+ if (!pre.allow)
101
+ return deniedPromptResult(pre.reason);
102
+ const effectiveArgs = pre.payload?.arguments ?? args;
103
+ const result = await handler(effectiveArgs, extra);
104
+ const post = await registry.fire("prompt_post_fetch", { ...ctx, kind: "prompt_post_fetch" }, { name: ctx.target, arguments: effectiveArgs, messages: result?.messages });
105
+ if (!post.allow)
106
+ return deniedPromptResult(post.reason);
107
+ const overrideMessages = post.payload?.messages;
108
+ if (overrideMessages !== undefined && result && typeof result === "object") {
109
+ return { ...result, messages: overrideMessages };
110
+ }
111
+ return result;
112
+ };
113
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { HookRegistry } from "./hooks.js";
4
+ import { wrapToolHandler, wrapResourceHandler, wrapPromptHandler, } from "./hook-wrappers.js";
5
+ const CTX = { principal: "alice", tenant: "default", target: "x" };
6
+ // --- tool ------------------------------------------------------------
7
+ test("wrapToolHandler: no hooks → pass-through", async () => {
8
+ const reg = new HookRegistry();
9
+ const wrapped = wrapToolHandler(reg, CTX, async (args) => ({ content: [{ type: "text", text: `got ${JSON.stringify(args)}` }] }));
10
+ const r = await wrapped({ q: 1 }, undefined);
11
+ assert.deepEqual(r, { content: [{ type: "text", text: 'got {"q":1}' }] });
12
+ });
13
+ test("wrapToolHandler: pre-invoke denial → isError + reason; handler NOT called", async () => {
14
+ const reg = new HookRegistry();
15
+ let called = false;
16
+ reg.register({
17
+ pluginName: "guard",
18
+ kind: "tool_pre_invoke",
19
+ handler: () => ({ allow: false, reason: "blocked" }),
20
+ });
21
+ const wrapped = wrapToolHandler(reg, CTX, async () => {
22
+ called = true;
23
+ return { content: [] };
24
+ });
25
+ const r = await wrapped({}, undefined);
26
+ assert.equal(called, false);
27
+ assert.deepEqual(r, { content: [{ type: "text", text: "blocked" }], isError: true });
28
+ });
29
+ test("wrapToolHandler: pre-invoke args mutation flows into handler", async () => {
30
+ const reg = new HookRegistry();
31
+ reg.register({
32
+ pluginName: "enrich",
33
+ kind: "tool_pre_invoke",
34
+ handler: (_ctx, payload) => ({
35
+ allow: true,
36
+ payload: { args: { ...payload.args, injected: true } },
37
+ }),
38
+ });
39
+ let observedArgs;
40
+ const wrapped = wrapToolHandler(reg, CTX, async (args) => {
41
+ observedArgs = args;
42
+ return { content: [] };
43
+ });
44
+ await wrapped({ original: 1 }, undefined);
45
+ assert.deepEqual(observedArgs, { original: 1, injected: true });
46
+ });
47
+ test("wrapToolHandler: post-invoke result mutation flows back to caller", async () => {
48
+ const reg = new HookRegistry();
49
+ reg.register({
50
+ pluginName: "redact",
51
+ kind: "tool_post_invoke",
52
+ handler: () => ({ allow: true, payload: { result: { content: [{ type: "text", text: "REDACTED" }] } } }),
53
+ });
54
+ const wrapped = wrapToolHandler(reg, CTX, async () => ({
55
+ content: [{ type: "text", text: "secret-value" }],
56
+ }));
57
+ const r = await wrapped({}, undefined);
58
+ assert.deepEqual(r, { content: [{ type: "text", text: "REDACTED" }] });
59
+ });
60
+ // --- resource --------------------------------------------------------
61
+ test("wrapResourceHandler: no hooks → pass-through with original URI", async () => {
62
+ const reg = new HookRegistry();
63
+ let observed;
64
+ const wrapped = wrapResourceHandler(reg, CTX, async (uri) => {
65
+ observed = uri;
66
+ return { contents: [{ uri: String(uri), text: "hi" }] };
67
+ });
68
+ const r = await wrapped("file:///a", undefined);
69
+ assert.equal(observed, "file:///a");
70
+ assert.deepEqual(r, { contents: [{ uri: "file:///a", text: "hi" }] });
71
+ });
72
+ test("wrapResourceHandler: pre-fetch denial returns structured error; handler NOT called", async () => {
73
+ const reg = new HookRegistry();
74
+ let called = false;
75
+ reg.register({
76
+ pluginName: "guard",
77
+ kind: "resource_pre_fetch",
78
+ handler: () => ({ allow: false, reason: "forbidden uri" }),
79
+ });
80
+ const wrapped = wrapResourceHandler(reg, CTX, async () => {
81
+ called = true;
82
+ return { contents: [] };
83
+ });
84
+ const r = await wrapped("file:///secret", undefined);
85
+ assert.equal(called, false);
86
+ assert.deepEqual(r, {
87
+ contents: [{ uri: "file:///secret", mimeType: "text/plain", text: "forbidden uri" }],
88
+ isError: true,
89
+ });
90
+ });
91
+ test("wrapResourceHandler: pre-fetch URI mutation flows into handler", async () => {
92
+ const reg = new HookRegistry();
93
+ reg.register({
94
+ pluginName: "canon",
95
+ kind: "resource_pre_fetch",
96
+ handler: () => ({ allow: true, payload: { uri: "file:///canonical" } }),
97
+ });
98
+ let observed;
99
+ const wrapped = wrapResourceHandler(reg, CTX, async (uri) => {
100
+ observed = uri;
101
+ return { contents: [{ uri: String(uri), text: "ok" }] };
102
+ });
103
+ await wrapped("file:///raw", undefined);
104
+ assert.equal(observed, "file:///canonical");
105
+ });
106
+ test("wrapResourceHandler: URL instance preserved across mutation", async () => {
107
+ const reg = new HookRegistry();
108
+ reg.register({
109
+ pluginName: "canon",
110
+ kind: "resource_pre_fetch",
111
+ handler: () => ({ allow: true, payload: { uri: "https://new.example/path" } }),
112
+ });
113
+ let observed;
114
+ const wrapped = wrapResourceHandler(reg, CTX, async (uri) => {
115
+ observed = uri;
116
+ return { contents: [{ uri: String(uri), text: "ok" }] };
117
+ });
118
+ await wrapped(new URL("https://old.example/path"), undefined);
119
+ assert.ok(observed instanceof URL, "mutated URI should still be a URL when caller passed one");
120
+ assert.equal(String(observed), "https://new.example/path");
121
+ });
122
+ test("wrapResourceHandler: post-fetch contents replacement", async () => {
123
+ const reg = new HookRegistry();
124
+ reg.register({
125
+ pluginName: "censor",
126
+ kind: "resource_post_fetch",
127
+ handler: () => ({ allow: true, payload: { contents: [{ uri: "file:///x", text: "[censored]" }] } }),
128
+ });
129
+ const wrapped = wrapResourceHandler(reg, CTX, async () => ({
130
+ contents: [{ uri: "file:///x", text: "raw" }],
131
+ _meta: { kept: true },
132
+ }));
133
+ const r = (await wrapped("file:///x", undefined));
134
+ assert.deepEqual(r.contents, [{ uri: "file:///x", text: "[censored]" }]);
135
+ // Other top-level keys survive the mutation
136
+ assert.deepEqual(r._meta, { kept: true });
137
+ });
138
+ // --- prompt ----------------------------------------------------------
139
+ test("wrapPromptHandler: no hooks → pass-through", async () => {
140
+ const reg = new HookRegistry();
141
+ const wrapped = wrapPromptHandler(reg, { ...CTX, target: "greet" }, async (args) => ({
142
+ description: "ok",
143
+ messages: [{ role: "user", content: { type: "text", text: `hi ${JSON.stringify(args)}` } }],
144
+ }));
145
+ const r = await wrapped({ who: "world" }, undefined);
146
+ assert.deepEqual(r, {
147
+ description: "ok",
148
+ messages: [{ role: "user", content: { type: "text", text: 'hi {"who":"world"}' } }],
149
+ });
150
+ });
151
+ test("wrapPromptHandler: pre-fetch denial returns structured error; handler NOT called", async () => {
152
+ const reg = new HookRegistry();
153
+ let called = false;
154
+ reg.register({
155
+ pluginName: "guard",
156
+ kind: "prompt_pre_fetch",
157
+ handler: () => ({ allow: false, reason: "denied" }),
158
+ });
159
+ const wrapped = wrapPromptHandler(reg, CTX, async () => {
160
+ called = true;
161
+ return { description: "x", messages: [] };
162
+ });
163
+ const r = await wrapped({}, undefined);
164
+ assert.equal(called, false);
165
+ assert.deepEqual(r, { description: "denied", messages: [], isError: true });
166
+ });
167
+ test("wrapPromptHandler: pre-fetch arguments mutation flows into handler", async () => {
168
+ const reg = new HookRegistry();
169
+ reg.register({
170
+ pluginName: "augment",
171
+ kind: "prompt_pre_fetch",
172
+ handler: (_ctx, payload) => ({
173
+ allow: true,
174
+ payload: { name: payload.name, arguments: { ...payload.arguments, extra: 1 } },
175
+ }),
176
+ });
177
+ let observed;
178
+ const wrapped = wrapPromptHandler(reg, CTX, async (args) => {
179
+ observed = args;
180
+ return { description: "", messages: [] };
181
+ });
182
+ await wrapped({ original: true }, undefined);
183
+ assert.deepEqual(observed, { original: true, extra: 1 });
184
+ });
185
+ test("wrapPromptHandler: post-fetch messages replacement", async () => {
186
+ const reg = new HookRegistry();
187
+ reg.register({
188
+ pluginName: "rewrite",
189
+ kind: "prompt_post_fetch",
190
+ handler: () => ({
191
+ allow: true,
192
+ payload: {
193
+ messages: [{ role: "system", content: { type: "text", text: "rewritten" } }],
194
+ },
195
+ }),
196
+ });
197
+ const wrapped = wrapPromptHandler(reg, CTX, async () => ({
198
+ description: "ok",
199
+ messages: [{ role: "user", content: { type: "text", text: "raw" } }],
200
+ }));
201
+ const r = (await wrapped({}, undefined));
202
+ assert.equal(r.description, "ok");
203
+ assert.deepEqual(r.messages, [{ role: "system", content: { type: "text", text: "rewritten" } }]);
204
+ });
@@ -42,6 +42,19 @@ export interface ConnectorManifest {
42
42
  * server runs with VERIFY_PLUGINS=true. See docs/plugin-architecture.md.
43
43
  */
44
44
  integrity?: string;
45
+ /**
46
+ * Lifecycle hooks the plugin wants auto-registered on load. Each
47
+ * entry points to a module path INSIDE the plugin's bundled files;
48
+ * the loader imports its default export and registers it on the
49
+ * gateway's HookRegistry. Mirrors the Zod manifestSchema in
50
+ * mcp-server/src/sdk/manifest-schema.ts. See Q10 / phase-q-sprint.md.
51
+ */
52
+ hooks?: Array<{
53
+ kind: "tool_pre_invoke" | "tool_post_invoke" | "resource_pre_fetch" | "resource_post_fetch" | "prompt_pre_fetch" | "prompt_post_fetch";
54
+ module: string;
55
+ priority?: number;
56
+ mode?: "enforce" | "permissive" | "disabled";
57
+ }>;
45
58
  }
46
59
  /**
47
60
  * The default export shape a connector plugin module must provide.
@@ -22,11 +22,22 @@ export declare const detectAnomaliesDefinition: {
22
22
  };
23
23
  };
24
24
  };
25
+ export interface AnomalyHistorySink {
26
+ record(entry: {
27
+ ts: string;
28
+ service: string;
29
+ tenant: string;
30
+ score: number;
31
+ method: string;
32
+ severity: string;
33
+ signal?: string;
34
+ }): Promise<void> | void;
35
+ }
25
36
  export declare function detectAnomaliesHandler(registry: ConnectorRegistry, args: {
26
37
  service?: string;
27
38
  duration?: string;
28
39
  sensitivity?: string;
29
- }, ctx?: RequestContext): Promise<{
40
+ }, ctx?: RequestContext, history?: AnomalyHistorySink): Promise<{
30
41
  content: {
31
42
  type: "text";
32
43
  text: string;
@@ -33,7 +33,7 @@ const KEY_METRICS = ["cpu", "memory", "error_rate", "latency_p99", "request_rate
33
33
  // the overall error ratio is low (e.g. a memory leak emits a handful of
34
34
  // "OutOfMemoryWarning" lines long before it turns into 5xx errors).
35
35
  const CRITICAL_LOG_PATTERN = /\b(out\s?of\s?memory|oom|outofmemory|heap (usage|exhaust)|memory leak|panic|fatal|deadlock|segfault|stack overflow|cannot allocate)\b/i;
36
- export async function detectAnomaliesHandler(registry, args, ctx = defaultContext()) {
36
+ export async function detectAnomaliesHandler(registry, args, ctx = defaultContext(), history) {
37
37
  const duration = args.duration || "10m";
38
38
  const threshold = SENSITIVITY_THRESHOLDS[args.sensitivity || "medium"] || 2.0;
39
39
  // Discover services to scan — tenant-scoped.
@@ -72,9 +72,10 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
72
72
  const deviationPercent = anomaly.baselineValue === 0
73
73
  ? 100
74
74
  : Math.round(((anomaly.recentValue - anomaly.baselineValue) / anomaly.baselineValue) * 100);
75
+ const severityLabel = Math.abs(anomaly.score) >= 6 ? "high" : Math.abs(anomaly.score) >= 4 ? "medium" : "low";
75
76
  allAnomalies.push({
76
77
  metric,
77
- severity: Math.abs(anomaly.score) >= 6 ? "high" : Math.abs(anomaly.score) >= 4 ? "medium" : "low",
78
+ severity: severityLabel,
78
79
  description: `${metric}: ${anomaly.reason}`,
79
80
  currentValue: anomaly.recentValue,
80
81
  baselineValue: anomaly.baselineValue,
@@ -82,6 +83,25 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
82
83
  source: connector.name,
83
84
  service: serviceName,
84
85
  });
86
+ // Phase P1: mirror the score to the TSDB sink (no-op if no
87
+ // sink wired). Best-effort — a slow / down sink must never
88
+ // block the detector loop, which is why we don't await.
89
+ if (history) {
90
+ try {
91
+ void history.record({
92
+ ts: new Date().toISOString(),
93
+ service: serviceName,
94
+ tenant: ctx.tenant || "default",
95
+ score: Math.abs(anomaly.score),
96
+ method: anomaly.method === "seasonal" ? "seasonality"
97
+ : anomaly.method === "robust-z" ? "mad"
98
+ : anomaly.method,
99
+ severity: severityLabel === "high" ? "critical" : severityLabel === "medium" ? "warn" : "info",
100
+ signal: metric,
101
+ });
102
+ }
103
+ catch { /* swallow — best-effort */ }
104
+ }
85
105
  }
86
106
  }
87
107
  catch {
@@ -14,10 +14,10 @@
14
14
  // connector later requires zero changes here.
15
15
  import { isTopologyProvider } from "../connectors/interface.js";
16
16
  import { defaultContext } from "../context.js";
17
+ import { mergeTopologies } from "../topology/merge.js";
17
18
  export async function aggregateTopology(registry, tenant) {
18
19
  const sources = [];
19
- const resources = [];
20
- const edges = [];
20
+ const snapshots = [];
21
21
  // Tenant-scoped when a tenant is supplied (call sites at the MCP
22
22
  // tool layer pass ctx.tenant); undefined preserves the original
23
23
  // global behaviour for internal / non-request callers.
@@ -34,14 +34,32 @@ export async function aggregateTopology(registry, tenant) {
34
34
  resources: snap.resources.length,
35
35
  edges: snap.edges.length,
36
36
  });
37
- resources.push(...snap.resources);
38
- edges.push(...snap.edges);
37
+ snapshots.push(snap);
39
38
  }
40
39
  catch {
41
40
  // A misbehaving connector must not poison the agent's view of the graph.
42
41
  }
43
42
  }
44
- return { sources, resources, edges };
43
+ // P1: run the snapshots through mergeTopologies so workloads
44
+ // surfaced by more than one provider (e.g. the same Deployment
45
+ // observed by both Kubernetes + a service-mesh connector) collapse
46
+ // into a single canonical node and edges are rewritten to match.
47
+ //
48
+ // ONLY engages for multi-source topologies — with a single snapshot
49
+ // the merger would mis-group intra-source siblings that happen to
50
+ // share a canonical label (e.g. two pod replicas with
51
+ // `app.kubernetes.io/name=api`). The merger is designed for
52
+ // cross-provider de-duplication, not intra-provider.
53
+ if (snapshots.length <= 1) {
54
+ const only = snapshots[0];
55
+ return {
56
+ sources,
57
+ resources: only?.resources ?? [],
58
+ edges: only?.edges ?? [],
59
+ };
60
+ }
61
+ const merged = mergeTopologies(snapshots);
62
+ return { sources, resources: merged.resources, edges: merged.edges };
45
63
  }
46
64
  /**
47
65
  * Resolve a caller-supplied identifier to a Resource. Accepts:
@@ -208,3 +208,48 @@ describe("get_blast_radius tool", () => {
208
208
  assert.equal(apiBucket.ownershipRootKind, "deployment");
209
209
  });
210
210
  });
211
+ // --- Multi-source merge (Phase P1 wiring) ----------------------------
212
+ // `aggregateTopology` now delegates to `mergeTopologies` when 2+
213
+ // snapshots are present so the same logical workload reported by
214
+ // e.g. Kubernetes + a cloud connector collapses into one node.
215
+ // Single-snapshot calls pass through unchanged (guarded so we don't
216
+ // mis-merge intra-source siblings that share an `app:` label).
217
+ describe("aggregateTopology — multi-source merger (P1 wire)", () => {
218
+ it("collapses cross-source duplicates that share a canonical label", async () => {
219
+ // Source A (k8s): one Deployment "checkout" in prod
220
+ const aRes = [
221
+ { id: "k8s:deployment:prod/checkout", kind: "deployment", name: "checkout", source: "k8s",
222
+ labels: { "app.kubernetes.io/name": "checkout" } },
223
+ ];
224
+ // Source B (trace provider): the same logical service
225
+ const bRes = [
226
+ { id: "tempo:service:checkout", kind: "trace_service", name: "checkout", source: "tempo",
227
+ labels: { "service.name": "checkout" } },
228
+ ];
229
+ const loader = new PluginLoader();
230
+ const reg = new ConnectorRegistry(loader);
231
+ const connA = new FakeTopologyConnector(aRes, []);
232
+ const connB = new FakeTopologyConnector(bRes, []);
233
+ await connA.connect({ name: "k8s", type: "fake", url: "", enabled: true });
234
+ await connB.connect({ name: "tempo", type: "fake", url: "", enabled: true });
235
+ const loaderInternal = loader;
236
+ loaderInternal.connectors.set("fake-a", { name: "fake-a", source: "builtin", factory: () => connA });
237
+ loaderInternal.connectors.set("fake-b", { name: "fake-b", source: "builtin", factory: () => connB });
238
+ await reg.addSource({ name: "k8s", type: "fake-a", url: "", enabled: true });
239
+ await reg.addSource({ name: "tempo", type: "fake-b", url: "", enabled: true });
240
+ const out = parseTool(await getTopologyHandler(reg, {}));
241
+ // 2 sources reported in summary
242
+ assert.equal(out.sources.length, 2);
243
+ // But ONE resource after merge (deployment + trace_service of the
244
+ // same canonical name collapse via MERGEABLE_KIND_PAIRS).
245
+ assert.equal(out.resources.length, 1);
246
+ assert.equal(out.resources[0].name, "checkout");
247
+ });
248
+ it("single-source passes through unchanged (no intra-source merging)", async () => {
249
+ // The existing 4-pod fixture has two pods sharing `app: api`.
250
+ // With a single snapshot the merger must NOT collapse them.
251
+ const reg = await makeRegistry();
252
+ const out = parseTool(await getTopologyHandler(reg, {}));
253
+ assert.equal(out.resources.length, fixture().resources.length);
254
+ });
255
+ });
@@ -0,0 +1,70 @@
1
+ import type { SessionStore } from "./sessionStore.js";
2
+ export interface TransportSessionMeta {
3
+ /** Stable id of the replica that owns the underlying SDK
4
+ * Transport object. Set on creation. */
5
+ ownerReplica: string;
6
+ /** Optional virtual-server product slug. Undefined for the
7
+ * root /mcp surface. */
8
+ product?: string;
9
+ /** Epoch ms — bumped on every successful request. */
10
+ lastActive: number;
11
+ }
12
+ export interface TransportSessionMap {
13
+ /** Stable backend identifier (used in /api/info diagnostics). */
14
+ readonly backend: string;
15
+ /** True iff there's a metadata entry — does NOT imply a local
16
+ * Transport exists. */
17
+ has(sessionId: string): Promise<boolean>;
18
+ get(sessionId: string): Promise<TransportSessionMeta | undefined>;
19
+ set(sessionId: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
20
+ /** Convenience: bump lastActive while preserving the rest. */
21
+ touch(sessionId: string, ttlSeconds?: number): Promise<void>;
22
+ delete(sessionId: string): Promise<void>;
23
+ /** Return every session id this map knows about. Used for the
24
+ * cleanup tick. Implementations MAY cap; in-memory returns the
25
+ * full set, Redis pages via SCAN under the prefix. */
26
+ keys(): Promise<string[]>;
27
+ /** Evict entries with lastActive older than `maxIdleMs`. Returns
28
+ * the evicted ids (caller logs / records metrics). */
29
+ cleanup(maxIdleMs: number): Promise<string[]>;
30
+ }
31
+ export declare class InMemoryTransportSessionMap implements TransportSessionMap {
32
+ readonly backend = "memory";
33
+ private readonly map;
34
+ has(id: string): Promise<boolean>;
35
+ get(id: string): Promise<TransportSessionMeta | undefined>;
36
+ set(id: string, meta: TransportSessionMeta): Promise<void>;
37
+ touch(id: string): Promise<void>;
38
+ delete(id: string): Promise<void>;
39
+ keys(): Promise<string[]>;
40
+ cleanup(maxIdleMs: number): Promise<string[]>;
41
+ }
42
+ /**
43
+ * Wraps an existing SessionStore so the per-session metadata lives
44
+ * wherever the SessionStore decided — InMemorySessionStore (no
45
+ * cross-replica visibility, identical to InMemoryTransportSessionMap
46
+ * but useful for tests / when a future backend is plugged in) or
47
+ * RedisSessionStore (cross-replica safe).
48
+ *
49
+ * Each entry stored at `<KEY_PREFIX><sessionId>` as JSON.
50
+ */
51
+ export declare class SessionStoreBackedTransportSessionMap implements TransportSessionMap {
52
+ readonly backend: string;
53
+ private readonly store;
54
+ private readonly defaultTtlSeconds;
55
+ constructor(store: SessionStore, defaultTtlSeconds?: number);
56
+ has(id: string): Promise<boolean>;
57
+ get(id: string): Promise<TransportSessionMeta | undefined>;
58
+ set(id: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
59
+ touch(id: string, ttlSeconds?: number): Promise<void>;
60
+ delete(id: string): Promise<void>;
61
+ keys(): Promise<string[]>;
62
+ cleanup(maxIdleMs: number): Promise<string[]>;
63
+ }
64
+ /**
65
+ * Pick the right implementation. When `sessionStore` is the
66
+ * in-memory default, return InMemoryTransportSessionMap so we
67
+ * avoid the (synchronous → async) layering tax. Otherwise wrap
68
+ * the supplied store.
69
+ */
70
+ export declare function createTransportSessionMap(sessionStore?: SessionStore): TransportSessionMap;