@thotischner/observability-mcp 1.7.1 → 3.0.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.
Files changed (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -0,0 +1,77 @@
1
+ /** Stable identifier for each hook point. Mirrors the canonical set
2
+ * the rest of the MCP ecosystem expects to see; extending this is
3
+ * a breaking change for the plugin contract. */
4
+ export type HookKind = "tool_pre_invoke" | "tool_post_invoke" | "resource_pre_fetch" | "resource_post_fetch" | "prompt_pre_fetch" | "prompt_post_fetch";
5
+ /** Hook-time context. Mirrors the RequestContext the gateway already
6
+ * carries but is intentionally a flat shape so plugins don't take a
7
+ * dependency on server internals. */
8
+ export interface HookContext {
9
+ /** Principal sub identifier (anonymous, OIDC sub, or local user). */
10
+ principal: string;
11
+ /** Tenant the principal is acting under. Always set; "default"
12
+ * when no tenancy is configured. */
13
+ tenant: string;
14
+ /** Hook fan-out kind. */
15
+ kind: HookKind;
16
+ /** Per-call metadata. Currently: tool name (for tool_*), resource
17
+ * URI (for resource_*), prompt name (for prompt_*). */
18
+ target: string;
19
+ /** Free-form labels — used by audit + by the hook itself to
20
+ * cooperate with siblings (e.g. correlation ids). */
21
+ labels?: Record<string, string>;
22
+ }
23
+ /** Hook-time payload. The exact shape depends on the hook kind:
24
+ * - tool_pre_invoke: { args: unknown }
25
+ * - tool_post_invoke: { args: unknown, result: unknown }
26
+ * - resource_pre_fetch: { uri: string }
27
+ * - resource_post_fetch: { uri: string, contents: unknown }
28
+ * - prompt_pre_fetch: { name: string, arguments: unknown }
29
+ * - prompt_post_fetch: { name: string, arguments: unknown, messages: unknown }
30
+ *
31
+ * Plugins may mutate the payload — the gateway forwards the mutated
32
+ * value to the next hook, then to the underlying handler / caller. */
33
+ export type HookPayload = Record<string, unknown>;
34
+ /** Hook result. `allow=false` short-circuits the dispatch with
35
+ * `reason` surfaced to the caller. `payload` (when present) replaces
36
+ * the current payload — used for redaction / transformation /
37
+ * enrichment. */
38
+ export interface HookResult {
39
+ allow: boolean;
40
+ payload?: HookPayload;
41
+ reason?: string;
42
+ }
43
+ /** A single hook registration. The plugin manifest carries one of
44
+ * these per hook entry; the loader instantiates the function from
45
+ * the plugin's source. */
46
+ export interface HookRegistration {
47
+ pluginName: string;
48
+ kind: HookKind;
49
+ /** Lower number runs earlier. Default 100 (mid-range). */
50
+ priority?: number;
51
+ /** enforce: blocking errors short-circuit. permissive: errors are
52
+ * logged and the chain continues with the prior payload.
53
+ * disabled: hook is loaded but not invoked (emergency disable). */
54
+ mode?: "enforce" | "permissive" | "disabled";
55
+ handler: (ctx: HookContext, payload: HookPayload) => Promise<HookResult> | HookResult;
56
+ }
57
+ /** Mutable, in-process registry. Plugin loaders push entries here;
58
+ * the dispatcher reads `fire()` per call.
59
+ *
60
+ * Hot-swap-safe: a re-registration with the same (pluginName, kind)
61
+ * replaces the prior entry — used by /api/connectors/install for
62
+ * zero-downtime hook updates. */
63
+ export declare class HookRegistry {
64
+ private entries;
65
+ /** Register or replace a hook entry. Returns the resolved registration. */
66
+ register(entry: HookRegistration): HookRegistration;
67
+ /** Remove all entries owned by a plugin (e.g. on uninstall). */
68
+ unregisterPlugin(pluginName: string): number;
69
+ /** All entries for a hook kind in priority order. */
70
+ list(kind: HookKind): HookRegistration[];
71
+ /** Snapshot of every registration regardless of kind (for diagnostics). */
72
+ all(): HookRegistration[];
73
+ /** Fire every hook of the given kind in priority order. Each hook
74
+ * receives the (possibly mutated) payload from the previous one.
75
+ * Short-circuits on first `allow:false`. */
76
+ fire(kind: HookKind, ctx: HookContext, initialPayload: HookPayload, logger?: (level: "warn" | "info", msg: string) => void): Promise<HookResult>;
77
+ }
@@ -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
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
  });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Multi-tenant context primitives.
3
+ *
4
+ * Every request lands in EXACTLY ONE tenant. Identities resolve to a
5
+ * tenant via one of three paths:
6
+ *
7
+ * 1. Anonymous (no auth, the demo / single-operator path) → DEFAULT_TENANT.
8
+ * 2. Basic-mode local user → user file's optional `tenant` field;
9
+ * missing → DEFAULT_TENANT (so existing single-tenant deployments
10
+ * keep working without any config change).
11
+ * 3. OIDC session → OMCP_OIDC_TENANT_CLAIM (default `tenant`);
12
+ * empty / missing claim → DEFAULT_TENANT.
13
+ * 4. MCP credential (bearer token) → optional per-credential
14
+ * `tenant` field assigned via OMCP_KEY_TENANTS env, mirroring
15
+ * OMCP_KEY_SOURCES / OMCP_KEY_BYPASS_REDACTION shape.
16
+ *
17
+ * The constant `DEFAULT_TENANT` is the universal escape hatch — any
18
+ * non-multi-tenant deployment behaves as if everything is in
19
+ * tenant `default`, identical to the pre-E7 single-namespace world.
20
+ *
21
+ * Cross-tenant requests return 404 (not 403) per the plan — leaking
22
+ * existence by status code defeats half the point of isolation.
23
+ */
24
+ export declare const DEFAULT_TENANT = "default";
25
+ /** Maximum tenant identifier length. Defence-in-depth against a
26
+ * hostile claim payload pushing arbitrary KB-sized strings through
27
+ * every cookie. Operators with longer names should pick shorter
28
+ * ones; the audit chain still hashes the full string but the
29
+ * cookie payload + log lines stay bounded. */
30
+ export declare const MAX_TENANT_LENGTH = 64;
31
+ /** Normalise + validate a tenant identifier. Returns the trimmed,
32
+ * lower-cased string when valid; DEFAULT_TENANT for empty or
33
+ * invalid input (silent fallback rather than crash — an OIDC claim
34
+ * with junk should drop the user into the safe default, not 500
35
+ * the whole flow). */
36
+ export declare function normaliseTenant(raw: unknown): string;
37
+ /** Walk a dotted-path claim out of an arbitrary claim set, then
38
+ * normalise. Used for OIDC sessions where the tenant lives at e.g.
39
+ * `app.tenant_id` rather than the top level. */
40
+ export declare function tenantFromClaim(claims: Record<string, unknown>, claimPath: string): string;
41
+ /** Parse OMCP_KEY_TENANTS="ci=acme;agent=bigco" into a name → tenant
42
+ * map. Mirrors parseKeySources in auth/credentials.ts so the operator
43
+ * cognitive load stays low. Invalid tenant strings normalise to
44
+ * DEFAULT_TENANT silently. */
45
+ export declare function parseKeyTenants(raw: string | undefined): Map<string, string>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Multi-tenant context primitives.
3
+ *
4
+ * Every request lands in EXACTLY ONE tenant. Identities resolve to a
5
+ * tenant via one of three paths:
6
+ *
7
+ * 1. Anonymous (no auth, the demo / single-operator path) → DEFAULT_TENANT.
8
+ * 2. Basic-mode local user → user file's optional `tenant` field;
9
+ * missing → DEFAULT_TENANT (so existing single-tenant deployments
10
+ * keep working without any config change).
11
+ * 3. OIDC session → OMCP_OIDC_TENANT_CLAIM (default `tenant`);
12
+ * empty / missing claim → DEFAULT_TENANT.
13
+ * 4. MCP credential (bearer token) → optional per-credential
14
+ * `tenant` field assigned via OMCP_KEY_TENANTS env, mirroring
15
+ * OMCP_KEY_SOURCES / OMCP_KEY_BYPASS_REDACTION shape.
16
+ *
17
+ * The constant `DEFAULT_TENANT` is the universal escape hatch — any
18
+ * non-multi-tenant deployment behaves as if everything is in
19
+ * tenant `default`, identical to the pre-E7 single-namespace world.
20
+ *
21
+ * Cross-tenant requests return 404 (not 403) per the plan — leaking
22
+ * existence by status code defeats half the point of isolation.
23
+ */
24
+ export const DEFAULT_TENANT = "default";
25
+ /** Maximum tenant identifier length. Defence-in-depth against a
26
+ * hostile claim payload pushing arbitrary KB-sized strings through
27
+ * every cookie. Operators with longer names should pick shorter
28
+ * ones; the audit chain still hashes the full string but the
29
+ * cookie payload + log lines stay bounded. */
30
+ export const MAX_TENANT_LENGTH = 64;
31
+ /** Pattern: alphanumeric + `-` + `_` + `.`. Mirrors what most CI
32
+ * identifiers accept. Rejects `/`, space, control chars (which
33
+ * could break filesystem layouts in slice 2). */
34
+ const VALID_TENANT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
35
+ /** Normalise + validate a tenant identifier. Returns the trimmed,
36
+ * lower-cased string when valid; DEFAULT_TENANT for empty or
37
+ * invalid input (silent fallback rather than crash — an OIDC claim
38
+ * with junk should drop the user into the safe default, not 500
39
+ * the whole flow). */
40
+ export function normaliseTenant(raw) {
41
+ if (typeof raw !== "string")
42
+ return DEFAULT_TENANT;
43
+ const trimmed = raw.trim().toLowerCase();
44
+ if (trimmed.length === 0)
45
+ return DEFAULT_TENANT;
46
+ if (trimmed.length > MAX_TENANT_LENGTH)
47
+ return DEFAULT_TENANT;
48
+ if (!VALID_TENANT_RE.test(trimmed))
49
+ return DEFAULT_TENANT;
50
+ return trimmed;
51
+ }
52
+ /** Walk a dotted-path claim out of an arbitrary claim set, then
53
+ * normalise. Used for OIDC sessions where the tenant lives at e.g.
54
+ * `app.tenant_id` rather than the top level. */
55
+ export function tenantFromClaim(claims, claimPath) {
56
+ if (!claimPath)
57
+ return DEFAULT_TENANT;
58
+ const parts = claimPath.split(".");
59
+ let cur = claims;
60
+ for (const p of parts) {
61
+ if (cur && typeof cur === "object" && !Array.isArray(cur) && p in cur) {
62
+ cur = cur[p];
63
+ }
64
+ else {
65
+ return DEFAULT_TENANT;
66
+ }
67
+ }
68
+ // Arrays: take the first string-shaped entry (the same posture as
69
+ // resolveRoles in auth/oidc/runtime.ts). Operators wanting per-call
70
+ // multi-tenancy should use one tenant claim per token, not a list.
71
+ if (Array.isArray(cur)) {
72
+ for (const v of cur)
73
+ if (typeof v === "string")
74
+ return normaliseTenant(v);
75
+ return DEFAULT_TENANT;
76
+ }
77
+ return normaliseTenant(cur);
78
+ }
79
+ /** Parse OMCP_KEY_TENANTS="ci=acme;agent=bigco" into a name → tenant
80
+ * map. Mirrors parseKeySources in auth/credentials.ts so the operator
81
+ * cognitive load stays low. Invalid tenant strings normalise to
82
+ * DEFAULT_TENANT silently. */
83
+ export function parseKeyTenants(raw) {
84
+ const out = new Map();
85
+ if (!raw)
86
+ return out;
87
+ for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
88
+ const eq = entry.indexOf("=");
89
+ if (eq <= 0)
90
+ continue;
91
+ const name = entry.slice(0, eq).trim();
92
+ const tenant = normaliseTenant(entry.slice(eq + 1).trim());
93
+ if (name)
94
+ out.set(name, tenant);
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { DEFAULT_TENANT, MAX_TENANT_LENGTH, normaliseTenant, tenantFromClaim, parseKeyTenants, } from "./context.js";
4
+ test("normaliseTenant — happy paths", () => {
5
+ assert.equal(normaliseTenant("acme"), "acme");
6
+ assert.equal(normaliseTenant("ACME"), "acme", "lowercases");
7
+ assert.equal(normaliseTenant(" acme-corp "), "acme-corp", "trims");
8
+ assert.equal(normaliseTenant("team_a.us-east"), "team_a.us-east");
9
+ });
10
+ test("normaliseTenant — invalid / empty / null falls back to default", () => {
11
+ assert.equal(normaliseTenant(undefined), DEFAULT_TENANT);
12
+ assert.equal(normaliseTenant(null), DEFAULT_TENANT);
13
+ assert.equal(normaliseTenant(""), DEFAULT_TENANT);
14
+ assert.equal(normaliseTenant(" "), DEFAULT_TENANT);
15
+ assert.equal(normaliseTenant(123), DEFAULT_TENANT);
16
+ // Disallowed shapes
17
+ assert.equal(normaliseTenant("acme/corp"), DEFAULT_TENANT, "slash rejected");
18
+ assert.equal(normaliseTenant("acme corp"), DEFAULT_TENANT, "space rejected");
19
+ assert.equal(normaliseTenant("..hidden"), DEFAULT_TENANT, "leading dot rejected");
20
+ assert.equal(normaliseTenant("-leading"), DEFAULT_TENANT, "leading dash rejected");
21
+ // Too long
22
+ assert.equal(normaliseTenant("x".repeat(MAX_TENANT_LENGTH + 1)), DEFAULT_TENANT);
23
+ });
24
+ test("normaliseTenant — exactly MAX_TENANT_LENGTH passes", () => {
25
+ const t = "a" + "x".repeat(MAX_TENANT_LENGTH - 1);
26
+ assert.equal(t.length, MAX_TENANT_LENGTH);
27
+ assert.equal(normaliseTenant(t), t);
28
+ });
29
+ test("tenantFromClaim — flat claim", () => {
30
+ assert.equal(tenantFromClaim({ tenant: "acme" }, "tenant"), "acme");
31
+ assert.equal(tenantFromClaim({ tenant: "ACME" }, "tenant"), "acme");
32
+ assert.equal(tenantFromClaim({}, "tenant"), DEFAULT_TENANT);
33
+ });
34
+ test("tenantFromClaim — dotted claim path", () => {
35
+ assert.equal(tenantFromClaim({ app: { tenant_id: "acme" } }, "app.tenant_id"), "acme");
36
+ assert.equal(tenantFromClaim({ app: { tenant_id: "acme" } }, "app.missing"), DEFAULT_TENANT);
37
+ assert.equal(tenantFromClaim({ app: { tenant_id: "acme" } }, "missing.path"), DEFAULT_TENANT);
38
+ });
39
+ test("tenantFromClaim — array claim takes first string entry", () => {
40
+ assert.equal(tenantFromClaim({ tenants: ["acme", "other"] }, "tenants"), "acme");
41
+ assert.equal(tenantFromClaim({ tenants: [123, "acme"] }, "tenants"), "acme");
42
+ assert.equal(tenantFromClaim({ tenants: [123, 456] }, "tenants"), DEFAULT_TENANT);
43
+ });
44
+ test("tenantFromClaim — non-string scalar falls back", () => {
45
+ assert.equal(tenantFromClaim({ tenant: 42 }, "tenant"), DEFAULT_TENANT);
46
+ assert.equal(tenantFromClaim({ tenant: true }, "tenant"), DEFAULT_TENANT);
47
+ assert.equal(tenantFromClaim({ tenant: null }, "tenant"), DEFAULT_TENANT);
48
+ });
49
+ test("tenantFromClaim — empty claimPath returns default", () => {
50
+ assert.equal(tenantFromClaim({ tenant: "acme" }, ""), DEFAULT_TENANT);
51
+ });
52
+ test("parseKeyTenants — happy path", () => {
53
+ const m = parseKeyTenants("ci=acme;agent=bigco; dev=team_a.us");
54
+ assert.equal(m.size, 3);
55
+ assert.equal(m.get("ci"), "acme");
56
+ assert.equal(m.get("agent"), "bigco");
57
+ assert.equal(m.get("dev"), "team_a.us");
58
+ });
59
+ test("parseKeyTenants — invalid tenant on the right-hand side normalises to default", () => {
60
+ const m = parseKeyTenants("ci=acme/corp;agent=BIGCO");
61
+ assert.equal(m.get("ci"), DEFAULT_TENANT, "slash → default");
62
+ assert.equal(m.get("agent"), "bigco", "uppercase OK after normalise");
63
+ });
64
+ test("parseKeyTenants — malformed entries skipped, doesn't crash", () => {
65
+ const m = parseKeyTenants("noequal;=novalueeither;valid=acme");
66
+ assert.equal(m.size, 1);
67
+ assert.equal(m.get("valid"), "acme");
68
+ });
69
+ test("parseKeyTenants — undefined / empty returns empty map", () => {
70
+ assert.equal(parseKeyTenants(undefined).size, 0);
71
+ assert.equal(parseKeyTenants("").size, 0);
72
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Migration regression suite — pre-E7 single-tenant deployments must
3
+ * continue to work without any config change. These tests pin the
4
+ * "everything defaults to `default`" contract by simulating the
5
+ * exact data shapes a pre-E7 server / file / token would carry.
6
+ */
7
+ export {};