@thotischner/observability-mcp 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/audit/sinks/s3.d.ts +61 -0
  5. package/dist/audit/sinks/s3.js +179 -0
  6. package/dist/audit/sinks/s3.test.d.ts +1 -0
  7. package/dist/audit/sinks/s3.test.js +175 -0
  8. package/dist/auth/csrf.d.ts +6 -0
  9. package/dist/auth/csrf.js +4 -0
  10. package/dist/auth/csrf.test.js +22 -0
  11. package/dist/auth/lockout.d.ts +72 -0
  12. package/dist/auth/lockout.js +134 -0
  13. package/dist/auth/lockout.test.d.ts +1 -0
  14. package/dist/auth/lockout.test.js +133 -0
  15. package/dist/auth/middleware.d.ts +5 -0
  16. package/dist/auth/middleware.js +6 -1
  17. package/dist/auth/middleware.test.js +31 -0
  18. package/dist/auth/password-policy.d.ts +52 -0
  19. package/dist/auth/password-policy.js +125 -0
  20. package/dist/auth/password-policy.test.d.ts +1 -0
  21. package/dist/auth/password-policy.test.js +111 -0
  22. package/dist/auth/policy/batch-dry-run.js +15 -0
  23. package/dist/auth/revocation.d.ts +93 -0
  24. package/dist/auth/revocation.js +193 -0
  25. package/dist/auth/revocation.test.d.ts +1 -0
  26. package/dist/auth/revocation.test.js +136 -0
  27. package/dist/auth/session.d.ts +7 -0
  28. package/dist/auth/session.js +6 -0
  29. package/dist/auth/session.test.js +21 -0
  30. package/dist/connectors/interface.d.ts +5 -1
  31. package/dist/connectors/loader.d.ts +8 -0
  32. package/dist/connectors/loader.js +49 -0
  33. package/dist/connectors/loki.d.ts +45 -1
  34. package/dist/connectors/loki.js +141 -8
  35. package/dist/connectors/loki.test.js +171 -1
  36. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  37. package/dist/connectors/manifest-hooks.test.js +206 -0
  38. package/dist/federation/registry.d.ts +27 -5
  39. package/dist/federation/registry.js +49 -4
  40. package/dist/federation/registry.test.js +79 -3
  41. package/dist/federation/upstream.d.ts +32 -6
  42. package/dist/federation/upstream.js +60 -12
  43. package/dist/federation/upstream.test.d.ts +1 -0
  44. package/dist/federation/upstream.test.js +118 -0
  45. package/dist/index.js +522 -67
  46. package/dist/metrics/self.d.ts +1 -0
  47. package/dist/metrics/self.js +8 -0
  48. package/dist/openapi.js +39 -0
  49. package/dist/openapi.test.js +1 -0
  50. package/dist/policy/redact.js +1 -1
  51. package/dist/postmortem/store.d.ts +34 -0
  52. package/dist/postmortem/store.js +113 -0
  53. package/dist/postmortem/store.test.d.ts +1 -0
  54. package/dist/postmortem/store.test.js +118 -0
  55. package/dist/scim/compliance.test.d.ts +1 -0
  56. package/dist/scim/compliance.test.js +169 -0
  57. package/dist/scim/factory.test.d.ts +1 -0
  58. package/dist/scim/factory.test.js +54 -0
  59. package/dist/scim/patch-ops.test.d.ts +1 -0
  60. package/dist/scim/patch-ops.test.js +100 -0
  61. package/dist/scim/redis-store.d.ts +38 -0
  62. package/dist/scim/redis-store.js +178 -0
  63. package/dist/scim/redis-store.test.d.ts +1 -0
  64. package/dist/scim/redis-store.test.js +138 -0
  65. package/dist/scim/routes.d.ts +27 -2
  66. package/dist/scim/routes.js +161 -15
  67. package/dist/scim/store.d.ts +40 -1
  68. package/dist/scim/store.js +23 -5
  69. package/dist/sdk/hook-wrappers.d.ts +39 -0
  70. package/dist/sdk/hook-wrappers.js +113 -0
  71. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  72. package/dist/sdk/hook-wrappers.test.js +204 -0
  73. package/dist/sdk/index.d.ts +13 -0
  74. package/dist/security/csp.d.ts +64 -0
  75. package/dist/security/csp.js +135 -0
  76. package/dist/security/csp.test.d.ts +1 -0
  77. package/dist/security/csp.test.js +97 -0
  78. package/dist/tools/detect-anomalies.d.ts +12 -1
  79. package/dist/tools/detect-anomalies.js +22 -2
  80. package/dist/tools/query-logs.d.ts +40 -0
  81. package/dist/tools/query-logs.js +69 -3
  82. package/dist/tools/topology.js +23 -5
  83. package/dist/tools/topology.test.js +45 -0
  84. package/dist/tools/validation.d.ts +13 -0
  85. package/dist/tools/validation.js +74 -0
  86. package/dist/tools/validation.test.js +54 -1
  87. package/dist/transport/transportSessionMap.d.ts +70 -0
  88. package/dist/transport/transportSessionMap.js +128 -0
  89. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  90. package/dist/transport/transportSessionMap.test.js +111 -0
  91. package/dist/types.d.ts +48 -0
  92. package/dist/ui/index.html +898 -116
  93. package/package.json +1 -1
@@ -23,7 +23,11 @@ import { ScimNotFoundError, ScimValidationError } from "./store.js";
23
23
  const constantTimeBearerMatch = (raw, expected) => {
24
24
  if (!raw)
25
25
  return false;
26
- const m = raw.match(/^Bearer\s+(.+)$/i);
26
+ // Use bounded whitespace ({1,16}) instead of `\s+` to avoid the
27
+ // polynomial-ReDoS class — `\s+` followed by `(.+)` backtracks on
28
+ // strings like "bearer<many spaces><payload>". 16 is generous;
29
+ // RFC 7235 only requires one space.
30
+ const m = raw.match(/^Bearer[ \t]{1,16}(.+)$/i);
27
31
  if (!m)
28
32
  return false;
29
33
  const a = Buffer.from(m[1].trim());
@@ -205,29 +209,171 @@ export function registerScimRoutes(app, deps) {
205
209
  function withGroups(u, store) {
206
210
  return { ...u, groups: store.groupsContaining(u.id) };
207
211
  }
208
- /** Translate a SCIM PatchOp into a partial resource patch. Minimal:
209
- * we accept `op: "replace"` with no path (whole-resource merge) or
210
- * with a single-segment path naming a top-level attribute. `add` and
211
- * `remove` for members/emails arrays are a follow-up the
212
- * Entra/Okta provisioning checklists exercise replace-only on the
213
- * attributes we expose. */
214
- function applyPatchOps(current, patch) {
212
+ // Allow-list of attribute names patchable via SCIM PatchOp.
213
+ // `applyPatchOps` writes into a plain object using a user-supplied
214
+ // path/key every key is gated through this set so a malicious
215
+ // client can't inject __proto__ / constructor / arbitrary fields.
216
+ const PATCH_ALLOWED_ATTRS = new Set([
217
+ "userName", "active", "displayName", "name", "emails", "externalId",
218
+ "members", "groups",
219
+ ]);
220
+ function safePatchKey(k) {
221
+ return typeof k === "string" && PATCH_ALLOWED_ATTRS.has(k);
222
+ }
223
+ // Multi-valued attributes that support add/remove element ops + filter
224
+ // segments. Everything else is treated as a scalar (replace/set).
225
+ const PATCH_ARRAY_ATTRS = new Set(["members", "emails", "groups"]);
226
+ /** Parse a PatchOp `path` into {attr, filter?}. Supports a bare
227
+ * attribute (`members`) and a single `eq` filter segment
228
+ * (`members[value eq "abc"]`). Returns null for anything that
229
+ * doesn't name an allow-listed attribute or that we don't model —
230
+ * the caller skips those ops (fail-closed). The sub-attribute in the
231
+ * filter is only ever READ for comparison, never written, so it
232
+ * can't drive prototype pollution. */
233
+ function parsePatchPath(path) {
234
+ const filterMatch = /^([A-Za-z][\w]*)\[\s*([A-Za-z][\w]*)\s+eq\s+"([^"]*)"\s*\]$/.exec(path);
235
+ if (filterMatch) {
236
+ const attr = filterMatch[1];
237
+ if (!safePatchKey(attr))
238
+ return null;
239
+ return { attr, filter: { sub: filterMatch[2], val: filterMatch[3] } };
240
+ }
241
+ if (safePatchKey(path))
242
+ return { attr: path };
243
+ return null;
244
+ }
245
+ // Always returns a FRESH array — never the caller's reference — so the
246
+ // add/remove machinery can push/filter without mutating the current
247
+ // resource in place (which would corrupt it across chained ops or
248
+ // repeated calls).
249
+ function asArray(v) {
250
+ if (Array.isArray(v))
251
+ return v.slice();
252
+ if (v == null)
253
+ return [];
254
+ return [v];
255
+ }
256
+ /** Element identity for dedup/removal on multi-valued attrs. SCIM
257
+ * members/emails carry a `value` key; fall back to JSON equality. */
258
+ function elemKey(e) {
259
+ if (e && typeof e === "object" && "value" in e) {
260
+ return String(e.value);
261
+ }
262
+ return JSON.stringify(e);
263
+ }
264
+ /** Translate a SCIM PatchOp request into a partial resource patch.
265
+ *
266
+ * Supported (RFC 7644 §3.5.2):
267
+ * - replace, no path → whole-resource merge of allow-listed keys
268
+ * - replace, path=attr → set that attribute
269
+ * - add, no path → merge; array attrs append (deduped), scalars set
270
+ * - add, path=arrayAttr → append value(s) to the array (deduped)
271
+ * - add, path=scalarAttr → set
272
+ * - remove, path=arrayAttr → clear the array
273
+ * - remove, path=arrayAttr[sub eq "x"] → drop matching elements
274
+ * - remove, path=scalarAttr → clear the attribute (set undefined)
275
+ *
276
+ * Array ops are computed against the CURRENT resource value and the
277
+ * full resulting array is returned in `out` (the store merges
278
+ * shallowly, so out[attr] replaces current[attr] wholesale).
279
+ *
280
+ * Every property name written into `out` is gated through
281
+ * `PATCH_ALLOWED_ATTRS` so a SCIM client can't inject __proto__ /
282
+ * constructor / arbitrary fields (CodeQL js/remote-property-injection +
283
+ * js/prototype-polluting-assignment). Filter sub-attributes are
284
+ * read-only. */
285
+ export function applyPatchOps(current, patch) {
215
286
  if (!current)
216
287
  throw new ScimNotFoundError("target resource not found");
217
288
  if (!patch?.Operations || !Array.isArray(patch.Operations))
218
289
  return {};
290
+ const cur = current;
219
291
  const out = {};
292
+ // Read the working value for an attr: prefer an in-progress value
293
+ // from a prior op in this same request, else the current resource.
294
+ const readAttr = (attr) => (attr in out ? out[attr] : cur[attr]);
220
295
  for (const op of patch.Operations) {
221
- if (op.op !== "replace")
222
- continue; // skip add/remove for F21a
223
- if (!op.path) {
224
- // value is a partial object — merge top-level keys
225
- if (op.value && typeof op.value === "object") {
226
- Object.assign(out, op.value);
296
+ const verb = (op.op || "").toLowerCase();
297
+ if (verb === "replace") {
298
+ if (!op.path) {
299
+ if (op.value && typeof op.value === "object") {
300
+ for (const [k, v] of Object.entries(op.value)) {
301
+ if (safePatchKey(k))
302
+ out[k] = v;
303
+ }
304
+ }
305
+ continue;
306
+ }
307
+ const parsed = parsePatchPath(op.path);
308
+ if (parsed && !parsed.filter)
309
+ out[parsed.attr] = op.value;
310
+ continue;
311
+ }
312
+ if (verb === "add") {
313
+ if (!op.path) {
314
+ if (op.value && typeof op.value === "object") {
315
+ for (const [k, v] of Object.entries(op.value)) {
316
+ if (!safePatchKey(k))
317
+ continue;
318
+ if (PATCH_ARRAY_ATTRS.has(k)) {
319
+ const existing = asArray(readAttr(k));
320
+ const seen = new Set(existing.map(elemKey));
321
+ for (const item of asArray(v)) {
322
+ if (!seen.has(elemKey(item))) {
323
+ existing.push(item);
324
+ seen.add(elemKey(item));
325
+ }
326
+ }
327
+ out[k] = existing;
328
+ }
329
+ else {
330
+ out[k] = v;
331
+ }
332
+ }
333
+ }
334
+ continue;
335
+ }
336
+ const parsed = parsePatchPath(op.path);
337
+ if (!parsed || parsed.filter)
338
+ continue;
339
+ if (PATCH_ARRAY_ATTRS.has(parsed.attr)) {
340
+ const existing = asArray(readAttr(parsed.attr));
341
+ const seen = new Set(existing.map(elemKey));
342
+ for (const item of asArray(op.value)) {
343
+ if (!seen.has(elemKey(item))) {
344
+ existing.push(item);
345
+ seen.add(elemKey(item));
346
+ }
347
+ }
348
+ out[parsed.attr] = existing;
349
+ }
350
+ else {
351
+ out[parsed.attr] = op.value;
352
+ }
353
+ continue;
354
+ }
355
+ if (verb === "remove") {
356
+ if (!op.path)
357
+ continue; // remove with no path is a no-op (nothing to target)
358
+ const parsed = parsePatchPath(op.path);
359
+ if (!parsed)
360
+ continue;
361
+ if (parsed.filter && PATCH_ARRAY_ATTRS.has(parsed.attr)) {
362
+ const existing = asArray(readAttr(parsed.attr));
363
+ const { sub, val } = parsed.filter;
364
+ out[parsed.attr] = existing.filter((e) => {
365
+ const ev = e && typeof e === "object" ? e[sub] : undefined;
366
+ return String(ev) !== val;
367
+ });
368
+ }
369
+ else if (PATCH_ARRAY_ATTRS.has(parsed.attr)) {
370
+ out[parsed.attr] = [];
371
+ }
372
+ else {
373
+ out[parsed.attr] = undefined;
227
374
  }
228
375
  continue;
229
376
  }
230
- out[op.path] = op.value;
231
377
  }
232
378
  return out;
233
379
  }
@@ -3,7 +3,30 @@ export interface ScimSnapshot {
3
3
  users: ScimUser[];
4
4
  groups: ScimGroup[];
5
5
  }
6
- export declare class ScimStore {
6
+ /**
7
+ * The store surface route handlers and group-role-map depend on.
8
+ * Both file + redis backends implement this. New backends (DynamoDB,
9
+ * Postgres, …) just need to satisfy these methods.
10
+ */
11
+ export interface IScimStore {
12
+ load(): Promise<void>;
13
+ listUsers(): ScimUser[];
14
+ getUser(id: string): ScimUser | undefined;
15
+ getUserByUserName(userName: string): ScimUser | undefined;
16
+ createUser(input: Partial<ScimUser>): Promise<ScimUser>;
17
+ updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
18
+ deleteUser(id: string): Promise<boolean>;
19
+ listGroups(): ScimGroup[];
20
+ getGroup(id: string): ScimGroup | undefined;
21
+ createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
22
+ updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
23
+ deleteGroup(id: string): Promise<boolean>;
24
+ groupsContaining(userId: string): Array<{
25
+ value: string;
26
+ display?: string;
27
+ }>;
28
+ }
29
+ export declare class ScimStore implements IScimStore {
7
30
  private readonly path;
8
31
  private snapshot;
9
32
  private bootstrapped;
@@ -35,3 +58,19 @@ export declare class ScimValidationError extends Error {
35
58
  export declare class ScimNotFoundError extends Error {
36
59
  constructor(message: string);
37
60
  }
61
+ /**
62
+ * Boot-time factory. Reads OMCP_SCIM_BACKEND and returns the matching
63
+ * implementation. Loadbearing on Helm too — values.yaml exposes a
64
+ * scim.backend toggle that ends up as the env var.
65
+ *
66
+ * config.path — file backend storage path (file backend only).
67
+ * config.redis — RedisLike client (redis backend only).
68
+ * config.redisKey — override default key name.
69
+ */
70
+ export interface CreateScimStoreConfig {
71
+ backend?: "file" | "redis";
72
+ path?: string;
73
+ redis?: import("./redis-store.js").RedisLike;
74
+ redisKey?: string;
75
+ }
76
+ export declare function createScimStore(config?: CreateScimStoreConfig): Promise<IScimStore>;
@@ -1,9 +1,11 @@
1
- // SCIM store — file-backed JSON for users + groups.
1
+ // SCIM store — file-backed JSON for users + groups (default), with a
2
+ // Redis-backed alternative for multi-replica deployments behind the
3
+ // `OMCP_SCIM_BACKEND=redis` env / `scim.backend: redis` Helm value.
2
4
  //
3
- // F21a uses an on-disk JSON file (atomic tmp+rename, mode 0600).
4
- // Multi-replica deployments should plug the F8 SessionStore in here
5
- // that's F21b. The interface intentionally mirrors what the
6
- // SessionStore exposes so the swap is purely additive.
5
+ // F21a shipped the on-disk JSON file (atomic tmp+rename, mode 0600).
6
+ // F21b (Q6) adds the Redis variant + the IScimStore interface every
7
+ // route handler now talks to. The factory `createScimStore` picks
8
+ // the implementation at boot; everything downstream is unchanged.
7
9
  import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
8
10
  import { dirname } from "node:path";
9
11
  import { randomUUID } from "node:crypto";
@@ -176,3 +178,19 @@ export class ScimNotFoundError extends Error {
176
178
  this.name = "ScimNotFoundError";
177
179
  }
178
180
  }
181
+ export async function createScimStore(config = {}) {
182
+ const backend = (config.backend || process.env.OMCP_SCIM_BACKEND || "file");
183
+ if (backend === "redis") {
184
+ if (!config.redis) {
185
+ throw new Error("createScimStore: backend=redis requires a redis client. Pass config.redis (RedisLike).");
186
+ }
187
+ const { RedisScimStore } = await import("./redis-store.js");
188
+ const store = new RedisScimStore(config.redis, { key: config.redisKey });
189
+ await store.load();
190
+ return store;
191
+ }
192
+ const path = config.path || process.env.OMCP_SCIM_STORE || "/var/lib/observability-mcp/scim.json";
193
+ const store = new ScimStore(path);
194
+ await store.load();
195
+ return store;
196
+ }
@@ -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.