@thotischner/observability-mcp 1.8.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,72 @@
1
+ // Plugin lifecycle hooks — interpose on every tool / resource /
2
+ // prompt invocation the gateway dispatches. Plugins declare hooks in
3
+ // their manifest; the HookRegistry resolves them on load and the
4
+ // dispatcher fires them around each call.
5
+ //
6
+ // Hooks land here as part of Phase F7 so Phase F9 (virtual servers)
7
+ // and Phase F10 (federation) can interpose without duplicating
8
+ // dispatch logic.
9
+ /** Mutable, in-process registry. Plugin loaders push entries here;
10
+ * the dispatcher reads `fire()` per call.
11
+ *
12
+ * Hot-swap-safe: a re-registration with the same (pluginName, kind)
13
+ * replaces the prior entry — used by /api/connectors/install for
14
+ * zero-downtime hook updates. */
15
+ export class HookRegistry {
16
+ entries = [];
17
+ /** Register or replace a hook entry. Returns the resolved registration. */
18
+ register(entry) {
19
+ this.entries = this.entries.filter((e) => !(e.pluginName === entry.pluginName && e.kind === entry.kind));
20
+ this.entries.push({
21
+ ...entry,
22
+ priority: entry.priority ?? 100,
23
+ mode: entry.mode ?? "enforce",
24
+ });
25
+ return entry;
26
+ }
27
+ /** Remove all entries owned by a plugin (e.g. on uninstall). */
28
+ unregisterPlugin(pluginName) {
29
+ const before = this.entries.length;
30
+ this.entries = this.entries.filter((e) => e.pluginName !== pluginName);
31
+ return before - this.entries.length;
32
+ }
33
+ /** All entries for a hook kind in priority order. */
34
+ list(kind) {
35
+ return this.entries
36
+ .filter((e) => e.kind === kind && e.mode !== "disabled")
37
+ .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
38
+ }
39
+ /** Snapshot of every registration regardless of kind (for diagnostics). */
40
+ all() {
41
+ return [...this.entries];
42
+ }
43
+ /** Fire every hook of the given kind in priority order. Each hook
44
+ * receives the (possibly mutated) payload from the previous one.
45
+ * Short-circuits on first `allow:false`. */
46
+ async fire(kind, ctx, initialPayload, logger = (l, m) => console[l === "warn" ? "warn" : "log"](m)) {
47
+ let payload = initialPayload;
48
+ for (const entry of this.list(kind)) {
49
+ try {
50
+ const r = await entry.handler({ ...ctx, kind }, payload);
51
+ if (!r.allow) {
52
+ return r;
53
+ }
54
+ if (r.payload)
55
+ payload = r.payload;
56
+ }
57
+ catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ if (entry.mode === "permissive") {
60
+ logger("warn", `hook ${entry.pluginName}/${kind} threw (permissive): ${msg}`);
61
+ continue;
62
+ }
63
+ // enforce: block the call.
64
+ return {
65
+ allow: false,
66
+ reason: `hook ${entry.pluginName}/${kind} failed: ${msg}`,
67
+ };
68
+ }
69
+ }
70
+ return { allow: true, payload };
71
+ }
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { HookRegistry } from "./hooks.js";
4
+ function ctx(target = "list_services") {
5
+ return {
6
+ principal: "alice",
7
+ tenant: "default",
8
+ kind: "tool_pre_invoke",
9
+ target,
10
+ };
11
+ }
12
+ test("HookRegistry.register: adds an entry with defaults applied", () => {
13
+ const r = new HookRegistry();
14
+ r.register({
15
+ pluginName: "p1",
16
+ kind: "tool_pre_invoke",
17
+ handler: () => ({ allow: true }),
18
+ });
19
+ const list = r.list("tool_pre_invoke");
20
+ assert.equal(list.length, 1);
21
+ assert.equal(list[0]?.priority, 100);
22
+ assert.equal(list[0]?.mode, "enforce");
23
+ });
24
+ test("HookRegistry.register: re-registering same (plugin,kind) replaces prior entry", () => {
25
+ const r = new HookRegistry();
26
+ r.register({ pluginName: "p", kind: "tool_pre_invoke", priority: 10, handler: () => ({ allow: true }) });
27
+ r.register({ pluginName: "p", kind: "tool_pre_invoke", priority: 20, handler: () => ({ allow: true }) });
28
+ const list = r.list("tool_pre_invoke");
29
+ assert.equal(list.length, 1);
30
+ assert.equal(list[0]?.priority, 20);
31
+ });
32
+ test("HookRegistry.list: orders by priority (lower runs first)", () => {
33
+ const r = new HookRegistry();
34
+ r.register({ pluginName: "a", kind: "tool_pre_invoke", priority: 50, handler: () => ({ allow: true }) });
35
+ r.register({ pluginName: "b", kind: "tool_pre_invoke", priority: 10, handler: () => ({ allow: true }) });
36
+ r.register({ pluginName: "c", kind: "tool_pre_invoke", priority: 99, handler: () => ({ allow: true }) });
37
+ const names = r.list("tool_pre_invoke").map((e) => e.pluginName);
38
+ assert.deepEqual(names, ["b", "a", "c"]);
39
+ });
40
+ test("HookRegistry.list: disabled hooks are filtered out", () => {
41
+ const r = new HookRegistry();
42
+ r.register({ pluginName: "a", kind: "tool_pre_invoke", handler: () => ({ allow: true }) });
43
+ r.register({ pluginName: "b", kind: "tool_pre_invoke", mode: "disabled", handler: () => ({ allow: true }) });
44
+ const names = r.list("tool_pre_invoke").map((e) => e.pluginName);
45
+ assert.deepEqual(names, ["a"]);
46
+ });
47
+ test("HookRegistry.unregisterPlugin: drops every entry for a plugin", () => {
48
+ const r = new HookRegistry();
49
+ r.register({ pluginName: "p", kind: "tool_pre_invoke", handler: () => ({ allow: true }) });
50
+ r.register({ pluginName: "p", kind: "tool_post_invoke", handler: () => ({ allow: true }) });
51
+ r.register({ pluginName: "q", kind: "tool_pre_invoke", handler: () => ({ allow: true }) });
52
+ const dropped = r.unregisterPlugin("p");
53
+ assert.equal(dropped, 2);
54
+ assert.equal(r.all().length, 1);
55
+ assert.equal(r.all()[0]?.pluginName, "q");
56
+ });
57
+ test("HookRegistry.fire: chains payload mutations and returns the final", async () => {
58
+ const r = new HookRegistry();
59
+ r.register({
60
+ pluginName: "a",
61
+ kind: "tool_pre_invoke",
62
+ priority: 10,
63
+ handler: (_c, p) => ({ allow: true, payload: { ...p, a: 1 } }),
64
+ });
65
+ r.register({
66
+ pluginName: "b",
67
+ kind: "tool_pre_invoke",
68
+ priority: 20,
69
+ handler: (_c, p) => ({ allow: true, payload: { ...p, b: 2 } }),
70
+ });
71
+ const result = await r.fire("tool_pre_invoke", ctx(), { initial: true });
72
+ assert.equal(result.allow, true);
73
+ assert.deepEqual(result.payload, { initial: true, a: 1, b: 2 });
74
+ });
75
+ test("HookRegistry.fire: first allow:false short-circuits subsequent hooks", async () => {
76
+ const r = new HookRegistry();
77
+ let sawSecond = false;
78
+ r.register({
79
+ pluginName: "a",
80
+ kind: "tool_pre_invoke",
81
+ priority: 10,
82
+ handler: () => ({ allow: false, reason: "denied by policy" }),
83
+ });
84
+ r.register({
85
+ pluginName: "b",
86
+ kind: "tool_pre_invoke",
87
+ priority: 20,
88
+ handler: () => {
89
+ sawSecond = true;
90
+ return { allow: true };
91
+ },
92
+ });
93
+ const result = await r.fire("tool_pre_invoke", ctx(), {});
94
+ assert.equal(result.allow, false);
95
+ assert.equal(result.reason, "denied by policy");
96
+ assert.equal(sawSecond, false);
97
+ });
98
+ test("HookRegistry.fire: enforce-mode throw blocks the chain", async () => {
99
+ const r = new HookRegistry();
100
+ let sawSecond = false;
101
+ r.register({
102
+ pluginName: "a",
103
+ kind: "tool_pre_invoke",
104
+ handler: () => {
105
+ throw new Error("boom");
106
+ },
107
+ });
108
+ r.register({
109
+ pluginName: "b",
110
+ kind: "tool_pre_invoke",
111
+ priority: 200,
112
+ handler: () => {
113
+ sawSecond = true;
114
+ return { allow: true };
115
+ },
116
+ });
117
+ const result = await r.fire("tool_pre_invoke", ctx(), {});
118
+ assert.equal(result.allow, false);
119
+ assert.match(result.reason ?? "", /boom/);
120
+ assert.equal(sawSecond, false);
121
+ });
122
+ test("HookRegistry.fire: permissive-mode throw is logged + chain continues with prior payload", async () => {
123
+ const r = new HookRegistry();
124
+ r.register({
125
+ pluginName: "a",
126
+ kind: "tool_pre_invoke",
127
+ priority: 10,
128
+ handler: (_c, p) => ({ allow: true, payload: { ...p, a: 1 } }),
129
+ });
130
+ r.register({
131
+ pluginName: "b",
132
+ kind: "tool_pre_invoke",
133
+ priority: 20,
134
+ mode: "permissive",
135
+ handler: () => {
136
+ throw new Error("intermittent failure");
137
+ },
138
+ });
139
+ r.register({
140
+ pluginName: "c",
141
+ kind: "tool_pre_invoke",
142
+ priority: 30,
143
+ handler: (_c, p) => ({ allow: true, payload: { ...p, c: 3 } }),
144
+ });
145
+ const logs = [];
146
+ const result = await r.fire("tool_pre_invoke", ctx(), {}, (lvl, m) => {
147
+ if (lvl === "warn")
148
+ logs.push(m);
149
+ });
150
+ assert.equal(result.allow, true);
151
+ assert.deepEqual(result.payload, { a: 1, c: 3 });
152
+ assert.equal(logs.length, 1);
153
+ assert.match(logs[0] ?? "", /b\/tool_pre_invoke/);
154
+ });
155
+ test("HookRegistry.fire: no hooks => allow with the initial payload", async () => {
156
+ const r = new HookRegistry();
157
+ const result = await r.fire("tool_pre_invoke", ctx(), { x: 1 });
158
+ assert.deepEqual(result, { allow: true, payload: { x: 1 } });
159
+ });
@@ -1,6 +1,8 @@
1
1
  export type { ObservabilityConnector } from "../connectors/interface.js";
2
2
  export { manifestSchema } from "./manifest-schema.js";
3
3
  export type { ValidatedConnectorManifest } from "./manifest-schema.js";
4
+ export { HookRegistry } from "./hooks.js";
5
+ export type { HookKind, HookContext, HookPayload, HookResult, HookRegistration, } from "./hooks.js";
4
6
  export type { SignalType, SourceConfig, SourceAuth, SourceTls, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, MetricSummary, DataPoint, LogQuery, LogResult, LogEntry, LogSummary, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeEvent, TopologyChangeListener, } from "../types.js";
5
7
  /**
6
8
  * Manifest shape declared in a plugin's `manifest.json`. The server
@@ -40,6 +42,19 @@ export interface ConnectorManifest {
40
42
  * server runs with VERIFY_PLUGINS=true. See docs/plugin-architecture.md.
41
43
  */
42
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
+ }>;
43
58
  }
44
59
  /**
45
60
  * The default export shape a connector plugin module must provide.
package/dist/sdk/index.js CHANGED
@@ -11,3 +11,4 @@
11
11
  // against that package and an `observabilityMcp.compat.serverVersion`
12
12
  // constraint in their package.json (see docs/plugin-architecture.md).
13
13
  export { manifestSchema } from "./manifest-schema.js";
14
+ export { HookRegistry } from "./hooks.js";
@@ -25,5 +25,22 @@ export declare const manifestSchema: z.ZodObject<{
25
25
  serverVersion: z.ZodOptional<z.ZodString>;
26
26
  }, z.core.$strip>>;
27
27
  integrity: z.ZodOptional<z.ZodString>;
28
+ hooks: z.ZodOptional<z.ZodArray<z.ZodObject<{
29
+ kind: z.ZodEnum<{
30
+ tool_pre_invoke: "tool_pre_invoke";
31
+ tool_post_invoke: "tool_post_invoke";
32
+ resource_pre_fetch: "resource_pre_fetch";
33
+ resource_post_fetch: "resource_post_fetch";
34
+ prompt_pre_fetch: "prompt_pre_fetch";
35
+ prompt_post_fetch: "prompt_post_fetch";
36
+ }>;
37
+ module: z.ZodString;
38
+ priority: z.ZodOptional<z.ZodNumber>;
39
+ mode: z.ZodOptional<z.ZodEnum<{
40
+ enforce: "enforce";
41
+ permissive: "permissive";
42
+ disabled: "disabled";
43
+ }>>;
44
+ }, z.core.$strip>>>;
28
45
  }, z.core.$strip>;
29
46
  export type ValidatedConnectorManifest = z.infer<typeof manifestSchema>;
@@ -44,4 +44,25 @@ export const manifestSchema = z.object({
44
44
  message: 'integrity must be "sha256-<base64>"',
45
45
  })
46
46
  .optional(),
47
+ // Lifecycle hooks the plugin wants to fire at. Each entry points to
48
+ // a module path INSIDE the plugin's bundled files. The loader
49
+ // resolves the module relative to the plugin root, imports its
50
+ // default export as the handler, and registers it on the gateway's
51
+ // HookRegistry. Hot-reloadable: install/upgrade of a plugin
52
+ // re-registers its hooks without restart.
53
+ hooks: z
54
+ .array(z.object({
55
+ kind: z.enum([
56
+ "tool_pre_invoke",
57
+ "tool_post_invoke",
58
+ "resource_pre_fetch",
59
+ "resource_post_fetch",
60
+ "prompt_pre_fetch",
61
+ "prompt_post_fetch",
62
+ ]),
63
+ module: z.string().min(1),
64
+ priority: z.number().int().optional(),
65
+ mode: z.enum(["enforce", "permissive", "disabled"]).optional(),
66
+ }))
67
+ .optional(),
47
68
  });
@@ -15,7 +15,12 @@ describe("RequestContext seam", () => {
15
15
  if (!hasHandler)
16
16
  continue;
17
17
  it(`${file}: handler accepts a RequestContext`, () => {
18
- assert.match(src, /_ctx:\s*RequestContext/, `${file} exports a *Handler but does not thread RequestContext` +
18
+ // Accept both the read-and-use form (`ctx: RequestContext`) and
19
+ // the historic placeholder form (`_ctx: RequestContext`) — the
20
+ // seam is the same; the underscore was only there to silence
21
+ // unused-param lints. Handlers that actually consume the ctx
22
+ // (tenant-aware tools, post-E7) drop it.
23
+ assert.match(src, /\b_?ctx:\s*RequestContext/, `${file} exports a *Handler but does not thread RequestContext — ` +
19
24
  `add the ctx seam (see context.ts)`);
20
25
  assert.match(src, /from "\.\.\/context\.js"/, `${file} must import from ../context.js`);
21
26
  });
@@ -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,12 +33,13 @@ 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
- // Discover services to scan
40
- const metricsConnectors = registry.getBySignal("metrics");
41
- const logConnectors = registry.getBySignal("logs");
39
+ // Discover services to scan — tenant-scoped.
40
+ const tenantConnectors = registry.getByTenant(ctx.tenant);
41
+ const metricsConnectors = tenantConnectors.filter((c) => c.signalType === "metrics");
42
+ const logConnectors = tenantConnectors.filter((c) => c.signalType === "logs");
42
43
  let serviceNames = [];
43
44
  if (args.service) {
44
45
  serviceNames = [args.service];
@@ -71,9 +72,10 @@ export async function detectAnomaliesHandler(registry, args, _ctx = defaultConte
71
72
  const deviationPercent = anomaly.baselineValue === 0
72
73
  ? 100
73
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";
74
76
  allAnomalies.push({
75
77
  metric,
76
- severity: Math.abs(anomaly.score) >= 6 ? "high" : Math.abs(anomaly.score) >= 4 ? "medium" : "low",
78
+ severity: severityLabel,
77
79
  description: `${metric}: ${anomaly.reason}`,
78
80
  currentValue: anomaly.recentValue,
79
81
  baselineValue: anomaly.baselineValue,
@@ -81,6 +83,25 @@ export async function detectAnomaliesHandler(registry, args, _ctx = defaultConte
81
83
  source: connector.name,
82
84
  service: serviceName,
83
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
+ }
84
105
  }
85
106
  }
86
107
  catch {
@@ -0,0 +1,35 @@
1
+ import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
3
+ export declare const generatePostmortemDefinition: {
4
+ name: "generate_postmortem";
5
+ description: string;
6
+ inputSchema: {
7
+ type: "object";
8
+ properties: {
9
+ service: {
10
+ type: string;
11
+ description: string;
12
+ };
13
+ duration: {
14
+ type: string;
15
+ description: string;
16
+ };
17
+ format: {
18
+ type: string;
19
+ description: string;
20
+ };
21
+ };
22
+ required: string[];
23
+ };
24
+ };
25
+ export declare function generatePostmortemHandler(registry: ConnectorRegistry, args: {
26
+ service: string;
27
+ duration?: string;
28
+ format?: string;
29
+ }, ctx?: RequestContext): Promise<{
30
+ content: {
31
+ type: "text";
32
+ text: string;
33
+ }[];
34
+ isError: boolean;
35
+ }>;
@@ -0,0 +1,191 @@
1
+ // generate_postmortem — Phase F19a.
2
+ //
3
+ // Stitches together anomaly history (F15), trace summaries (F13),
4
+ // and the topology blast-radius (existing get_blast_radius
5
+ // machinery) into a single markdown post-mortem report.
6
+ //
7
+ // The synthesizer is pure compute (see ./../postmortem/synthesizer);
8
+ // this handler is just the orchestration: pull each upstream
9
+ // primitive in parallel, hand the result to the synthesizer.
10
+ import { defaultContext } from "../context.js";
11
+ import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
12
+ import { synthesizePostmortem, } from "../postmortem/synthesizer.js";
13
+ export const generatePostmortemDefinition = {
14
+ name: "generate_postmortem",
15
+ description: [
16
+ "Stitch the gateway's primitives (anomaly history, blast-radius, traces, log highlights) into a single markdown post-mortem report for one service over a given window.",
17
+ "When to use: after an incident, when the operator or LLM wants 'one document the on-call can read in 60 seconds' instead of poking the individual tools.",
18
+ "Prerequisites: anomaly history requires OMCP_ANOMALY_HISTORY_REMOTE_WRITE configured AND a Prometheus source pointed at the same TSDB (see docs/anomaly-history.md). Traces require a Tempo / Jaeger source. Blast-radius requires a topology provider.",
19
+ "Behavior: read-only. Returns BOTH a structured JSON shape AND a markdown body suitable to paste straight into a ticket. Output is capped (timeline truncated to 20 rows in the markdown, 30 nodes in the blast radius table, 10 traces) — the structured shape carries the full data.",
20
+ "Related: `get_anomaly_history` for the raw scores; `query_traces` for individual traces; `get_blast_radius` for the topology.",
21
+ ].join(" "),
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ service: { type: "string", description: "Suspected root-cause service (the operator's first guess)." },
26
+ duration: { type: "string", description: "Rolling window the incident took place in, e.g. '1h', '6h'. Default '1h'." },
27
+ format: { type: "string", description: "Output format: 'markdown' (default) or 'json'." },
28
+ },
29
+ required: ["service"],
30
+ },
31
+ };
32
+ export async function generatePostmortemHandler(registry, args, ctx = defaultContext()) {
33
+ const svcErr = validateServiceName(args.service);
34
+ if (svcErr)
35
+ return errorResponse(svcErr);
36
+ const duration = args.duration || "1h";
37
+ const durationErr = validateDuration(duration);
38
+ if (durationErr)
39
+ return errorResponse(durationErr);
40
+ const now = new Date();
41
+ const fromIso = new Date(now.getTime() - parseDurationMs(duration)).toISOString();
42
+ const toIso = now.toISOString();
43
+ // Parallel-fetch every upstream primitive. Each fetch swallows
44
+ // its own errors and returns an empty result — the post-mortem
45
+ // must always synthesise SOMETHING (even "no signal found").
46
+ const [anomalies, traces, blastRadius, logHighlights] = await Promise.all([
47
+ fetchAnomalies(registry, args.service, duration, ctx),
48
+ fetchTraces(registry, args.service, duration, ctx),
49
+ fetchBlastRadius(registry, args.service, ctx),
50
+ fetchLogHighlights(registry, args.service, duration, ctx),
51
+ ]);
52
+ const report = synthesizePostmortem({
53
+ service: args.service,
54
+ window: duration,
55
+ tenant: ctx.tenant || "default",
56
+ fromIso,
57
+ toIso,
58
+ anomalies,
59
+ blastRadius,
60
+ traces,
61
+ logHighlights,
62
+ });
63
+ if ((args.format || "markdown").toLowerCase() === "json") {
64
+ return {
65
+ content: [{ type: "text", text: JSON.stringify(report) }],
66
+ isError: false,
67
+ };
68
+ }
69
+ // Default: return the markdown body. The structured sections live
70
+ // in JSON if the caller asked for them.
71
+ return {
72
+ content: [{ type: "text", text: report.markdown }],
73
+ isError: false,
74
+ };
75
+ }
76
+ function parseDurationMs(d) {
77
+ const m = d.match(/^(\d+)([smhd])$/);
78
+ if (!m)
79
+ return 60 * 60 * 1000;
80
+ const n = parseInt(m[1], 10);
81
+ const unit = m[2];
82
+ return unit === "s" ? n * 1000
83
+ : unit === "m" ? n * 60_000
84
+ : unit === "h" ? n * 3_600_000
85
+ : n * 86_400_000;
86
+ }
87
+ async function fetchAnomalies(registry, service, duration, ctx) {
88
+ const metric = `omcp_anomaly_score{service="${escLabel(service)}"}`;
89
+ for (const c of registry.getByTenant(ctx.tenant).filter((x) => typeof x.queryMetrics === "function")) {
90
+ try {
91
+ const r = await c.queryMetrics({ service, metric, duration });
92
+ if (r && r.values && r.values.length > 0) {
93
+ return r.values.map((v) => ({
94
+ ts: typeof v.timestamp === "number" ? new Date(v.timestamp).toISOString() : String(v.timestamp),
95
+ service,
96
+ score: typeof v.value === "number" ? v.value : Number(v.value) || 0,
97
+ method: "mad",
98
+ severity: "warn",
99
+ }));
100
+ }
101
+ }
102
+ catch {
103
+ /* fall through to next source */
104
+ }
105
+ }
106
+ return [];
107
+ }
108
+ async function fetchTraces(registry, service, duration, ctx) {
109
+ for (const c of registry.getByTenant(ctx.tenant).filter((x) => typeof x.queryTraces === "function")) {
110
+ try {
111
+ const r = await c.queryTraces({ service, duration, limit: 10 });
112
+ if (r && r.traces && r.traces.length > 0) {
113
+ return r.traces.map((t) => ({
114
+ traceId: t.traceId,
115
+ rootName: t.rootName,
116
+ rootService: t.rootService,
117
+ durationMs: t.durationMs,
118
+ hasError: t.hasError,
119
+ }));
120
+ }
121
+ }
122
+ catch {
123
+ /* fall through */
124
+ }
125
+ }
126
+ return [];
127
+ }
128
+ async function fetchBlastRadius(registry, service, ctx) {
129
+ // We don't have a direct "give me blast radius for service X" helper at
130
+ // this layer — the existing get_blast_radius is a tool that takes a
131
+ // resource id. For the post-mortem we settle for the full topology
132
+ // snapshot of the caller's tenant and let the synthesizer mark the
133
+ // suspect-named node as root. Future F19b can plumb the real walker.
134
+ for (const c of registry.getByTenant(ctx.tenant)) {
135
+ if (typeof c.getTopologySnapshot !== "function")
136
+ continue;
137
+ try {
138
+ const snap = await c.getTopologySnapshot();
139
+ if (!snap?.resources?.length)
140
+ continue;
141
+ // Pick nodes whose name matches the suspected service (case-
142
+ // insensitive substring is conservative-enough for the
143
+ // synopsis; the real walker can be precise later).
144
+ const needle = service.toLowerCase();
145
+ const matching = snap.resources.filter((r) => r.name?.toLowerCase().includes(needle) ||
146
+ (r.labels && Object.values(r.labels).some((v) => String(v).toLowerCase() === needle)));
147
+ if (matching.length === 0)
148
+ continue;
149
+ const matchedIds = new Set(matching.map((r) => r.id));
150
+ const connected = snap.edges.filter((e) => matchedIds.has(e.from) || matchedIds.has(e.to));
151
+ const neighborIds = new Set([
152
+ ...matching.map((r) => r.id),
153
+ ...connected.map((e) => e.from),
154
+ ...connected.map((e) => e.to),
155
+ ]);
156
+ const nodes = snap.resources
157
+ .filter((r) => neighborIds.has(r.id))
158
+ .map((r) => ({
159
+ id: r.id,
160
+ kind: r.kind,
161
+ name: r.name,
162
+ root: matchedIds.has(r.id),
163
+ }));
164
+ return {
165
+ nodes,
166
+ edges: connected.map((e) => ({ from: e.from, to: e.to, relation: e.relation })),
167
+ };
168
+ }
169
+ catch {
170
+ /* fall through */
171
+ }
172
+ }
173
+ return { nodes: [], edges: [] };
174
+ }
175
+ async function fetchLogHighlights(registry, service, duration, ctx) {
176
+ for (const c of registry.getByTenant(ctx.tenant).filter((x) => typeof x.queryLogs === "function")) {
177
+ try {
178
+ const r = await c.queryLogs({ service, duration, limit: 5 });
179
+ if (r?.summary?.errorCount && r.summary.errorCount > 0) {
180
+ return [`${service}: ${r.summary.errorCount} error log line(s) in window (source: ${r.source}).`];
181
+ }
182
+ }
183
+ catch {
184
+ /* skip */
185
+ }
186
+ }
187
+ return [];
188
+ }
189
+ function escLabel(v) {
190
+ return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
191
+ }