@thotischner/observability-mcp 1.8.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 (169) 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/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -0,0 +1,178 @@
1
+ // SCIM store — file-backed JSON for users + groups.
2
+ //
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.
7
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import { nowIso, SCIM_SCHEMA_GROUP, SCIM_SCHEMA_USER } from "./types.js";
11
+ const EMPTY = { users: [], groups: [] };
12
+ export class ScimStore {
13
+ path;
14
+ snapshot = EMPTY;
15
+ bootstrapped = null;
16
+ constructor(path) {
17
+ this.path = path;
18
+ }
19
+ async load() {
20
+ if (this.bootstrapped)
21
+ return this.bootstrapped;
22
+ this.bootstrapped = (async () => {
23
+ try {
24
+ const raw = await readFile(this.path, "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ this.snapshot = {
27
+ users: Array.isArray(parsed.users) ? parsed.users : [],
28
+ groups: Array.isArray(parsed.groups) ? parsed.groups : [],
29
+ };
30
+ }
31
+ catch (err) {
32
+ if (err.code === "ENOENT") {
33
+ this.snapshot = { users: [], groups: [] };
34
+ return;
35
+ }
36
+ console.warn(`[scim] failed to load ${this.path}: ${err.message} — starting empty`);
37
+ this.snapshot = { users: [], groups: [] };
38
+ }
39
+ })();
40
+ return this.bootstrapped;
41
+ }
42
+ listUsers() {
43
+ return this.snapshot.users.slice();
44
+ }
45
+ getUser(id) {
46
+ return this.snapshot.users.find((u) => u.id === id);
47
+ }
48
+ getUserByUserName(userName) {
49
+ return this.snapshot.users.find((u) => u.userName === userName);
50
+ }
51
+ async createUser(input) {
52
+ if (!input.userName)
53
+ throw new ScimValidationError("userName is required");
54
+ if (this.getUserByUserName(input.userName)) {
55
+ throw new ScimValidationError(`User with userName '${input.userName}' already exists`, "uniqueness");
56
+ }
57
+ const ts = nowIso();
58
+ const user = {
59
+ schemas: [SCIM_SCHEMA_USER],
60
+ id: randomUUID(),
61
+ userName: input.userName,
62
+ active: input.active ?? true,
63
+ displayName: input.displayName,
64
+ name: input.name,
65
+ emails: input.emails,
66
+ externalId: input.externalId,
67
+ meta: { resourceType: "User", created: ts, lastModified: ts },
68
+ };
69
+ this.snapshot.users.push(user);
70
+ await this.persist();
71
+ return user;
72
+ }
73
+ async updateUser(id, patch) {
74
+ const i = this.snapshot.users.findIndex((u) => u.id === id);
75
+ if (i < 0)
76
+ throw new ScimNotFoundError(`User ${id} not found`);
77
+ const next = {
78
+ ...this.snapshot.users[i],
79
+ ...patch,
80
+ schemas: [SCIM_SCHEMA_USER],
81
+ id,
82
+ meta: {
83
+ ...this.snapshot.users[i].meta,
84
+ lastModified: nowIso(),
85
+ },
86
+ };
87
+ this.snapshot.users[i] = next;
88
+ await this.persist();
89
+ return next;
90
+ }
91
+ async deleteUser(id) {
92
+ const before = this.snapshot.users.length;
93
+ this.snapshot.users = this.snapshot.users.filter((u) => u.id !== id);
94
+ if (this.snapshot.users.length === before)
95
+ return false;
96
+ // Also remove the user from every group's members list.
97
+ for (const g of this.snapshot.groups) {
98
+ g.members = (g.members ?? []).filter((m) => m.value !== id);
99
+ }
100
+ await this.persist();
101
+ return true;
102
+ }
103
+ listGroups() {
104
+ return this.snapshot.groups.slice();
105
+ }
106
+ getGroup(id) {
107
+ return this.snapshot.groups.find((g) => g.id === id);
108
+ }
109
+ async createGroup(input) {
110
+ if (!input.displayName)
111
+ throw new ScimValidationError("displayName is required");
112
+ const ts = nowIso();
113
+ const group = {
114
+ schemas: [SCIM_SCHEMA_GROUP],
115
+ id: randomUUID(),
116
+ displayName: input.displayName,
117
+ members: input.members ?? [],
118
+ externalId: input.externalId,
119
+ meta: { resourceType: "Group", created: ts, lastModified: ts },
120
+ };
121
+ this.snapshot.groups.push(group);
122
+ await this.persist();
123
+ return group;
124
+ }
125
+ async updateGroup(id, patch) {
126
+ const i = this.snapshot.groups.findIndex((g) => g.id === id);
127
+ if (i < 0)
128
+ throw new ScimNotFoundError(`Group ${id} not found`);
129
+ const next = {
130
+ ...this.snapshot.groups[i],
131
+ ...patch,
132
+ schemas: [SCIM_SCHEMA_GROUP],
133
+ id,
134
+ meta: {
135
+ ...this.snapshot.groups[i].meta,
136
+ lastModified: nowIso(),
137
+ },
138
+ };
139
+ this.snapshot.groups[i] = next;
140
+ await this.persist();
141
+ return next;
142
+ }
143
+ async deleteGroup(id) {
144
+ const before = this.snapshot.groups.length;
145
+ this.snapshot.groups = this.snapshot.groups.filter((g) => g.id !== id);
146
+ if (this.snapshot.groups.length === before)
147
+ return false;
148
+ await this.persist();
149
+ return true;
150
+ }
151
+ /** Look up the groups a user is currently a member of — used to
152
+ * populate `User.groups` on read responses. */
153
+ groupsContaining(userId) {
154
+ return this.snapshot.groups
155
+ .filter((g) => (g.members ?? []).some((m) => m.value === userId))
156
+ .map((g) => ({ value: g.id, display: g.displayName }));
157
+ }
158
+ async persist() {
159
+ await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
160
+ const tmp = `${this.path}.tmp`;
161
+ await writeFile(tmp, JSON.stringify(this.snapshot, null, 2), { mode: 0o600 });
162
+ await rename(tmp, this.path);
163
+ }
164
+ }
165
+ export class ScimValidationError extends Error {
166
+ scimType;
167
+ constructor(message, scimType) {
168
+ super(message);
169
+ this.scimType = scimType;
170
+ this.name = "ScimValidationError";
171
+ }
172
+ }
173
+ export class ScimNotFoundError extends Error {
174
+ constructor(message) {
175
+ super(message);
176
+ this.name = "ScimNotFoundError";
177
+ }
178
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, statSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { ScimStore, ScimValidationError, ScimNotFoundError } from "./store.js";
7
+ function tmpStore() {
8
+ return join(mkdtempSync(join(tmpdir(), "scim-")), "scim.json");
9
+ }
10
+ test("ScimStore: load() on missing file → empty snapshot", async () => {
11
+ const s = new ScimStore(tmpStore());
12
+ await s.load();
13
+ assert.deepEqual(s.listUsers(), []);
14
+ assert.deepEqual(s.listGroups(), []);
15
+ });
16
+ test("ScimStore: createUser issues UUID id, sets schemas/meta, active=true default", async () => {
17
+ const s = new ScimStore(tmpStore());
18
+ await s.load();
19
+ const u = await s.createUser({ userName: "alice@example.com" });
20
+ assert.match(u.id, /^[0-9a-f-]{36}$/);
21
+ assert.deepEqual(u.schemas, ["urn:ietf:params:scim:schemas:core:2.0:User"]);
22
+ assert.equal(u.userName, "alice@example.com");
23
+ assert.equal(u.active, true);
24
+ assert.equal(u.meta.resourceType, "User");
25
+ });
26
+ test("ScimStore: createUser rejects duplicate userName with uniqueness scimType", async () => {
27
+ const s = new ScimStore(tmpStore());
28
+ await s.load();
29
+ await s.createUser({ userName: "alice@example.com" });
30
+ await assert.rejects(() => s.createUser({ userName: "alice@example.com" }), (e) => e instanceof ScimValidationError && e.scimType === "uniqueness");
31
+ });
32
+ test("ScimStore: createUser rejects missing userName", async () => {
33
+ const s = new ScimStore(tmpStore());
34
+ await s.load();
35
+ await assert.rejects(() => s.createUser({}), ScimValidationError);
36
+ });
37
+ test("ScimStore: getUser / getUserByUserName lookups", async () => {
38
+ const s = new ScimStore(tmpStore());
39
+ await s.load();
40
+ const u = await s.createUser({ userName: "alice@example.com" });
41
+ assert.equal(s.getUser(u.id)?.id, u.id);
42
+ assert.equal(s.getUserByUserName("alice@example.com")?.id, u.id);
43
+ assert.equal(s.getUser("nope"), undefined);
44
+ });
45
+ test("ScimStore: updateUser merges patch + bumps lastModified", async () => {
46
+ const s = new ScimStore(tmpStore());
47
+ await s.load();
48
+ const u = await s.createUser({ userName: "alice@example.com" });
49
+ const created = u.meta.lastModified;
50
+ await new Promise((r) => setTimeout(r, 5));
51
+ const updated = await s.updateUser(u.id, { displayName: "Alice" });
52
+ assert.equal(updated.displayName, "Alice");
53
+ assert.equal(updated.userName, "alice@example.com");
54
+ assert.notEqual(updated.meta.lastModified, created);
55
+ });
56
+ test("ScimStore: updateUser on missing id throws NotFound", async () => {
57
+ const s = new ScimStore(tmpStore());
58
+ await s.load();
59
+ await assert.rejects(() => s.updateUser("nope", { displayName: "x" }), ScimNotFoundError);
60
+ });
61
+ test("ScimStore: deleteUser removes user + scrubs them from group members", async () => {
62
+ const s = new ScimStore(tmpStore());
63
+ await s.load();
64
+ const u = await s.createUser({ userName: "alice@example.com" });
65
+ const g = await s.createGroup({
66
+ displayName: "admins",
67
+ members: [{ value: u.id, display: "Alice" }],
68
+ });
69
+ assert.equal(await s.deleteUser(u.id), true);
70
+ assert.equal(s.getUser(u.id), undefined);
71
+ const refreshed = s.getGroup(g.id);
72
+ assert.deepEqual(refreshed?.members, []);
73
+ });
74
+ test("ScimStore: deleteUser missing → false", async () => {
75
+ const s = new ScimStore(tmpStore());
76
+ await s.load();
77
+ assert.equal(await s.deleteUser("nope"), false);
78
+ });
79
+ test("ScimStore: createGroup with displayName + member list", async () => {
80
+ const s = new ScimStore(tmpStore());
81
+ await s.load();
82
+ const u = await s.createUser({ userName: "alice@example.com" });
83
+ const g = await s.createGroup({
84
+ displayName: "admins",
85
+ members: [{ value: u.id, display: "Alice" }],
86
+ });
87
+ assert.equal(g.displayName, "admins");
88
+ assert.equal(g.members?.length, 1);
89
+ });
90
+ test("ScimStore: groupsContaining(userId) returns the groups a user is in", async () => {
91
+ const s = new ScimStore(tmpStore());
92
+ await s.load();
93
+ const u = await s.createUser({ userName: "alice@example.com" });
94
+ await s.createGroup({ displayName: "admins", members: [{ value: u.id }] });
95
+ await s.createGroup({ displayName: "viewers", members: [{ value: u.id }] });
96
+ await s.createGroup({ displayName: "irrelevant", members: [] });
97
+ const got = s.groupsContaining(u.id);
98
+ assert.equal(got.length, 2);
99
+ assert.deepEqual(got.map((g) => g.display).sort(), ["admins", "viewers"]);
100
+ });
101
+ test("ScimStore: persists to disk with mode 0o600 (atomic tmp+rename)", async () => {
102
+ const path = tmpStore();
103
+ const s = new ScimStore(path);
104
+ await s.load();
105
+ await s.createUser({ userName: "alice@example.com" });
106
+ assert.ok(existsSync(path));
107
+ const mode = statSync(path).mode & 0o777;
108
+ assert.equal(mode, 0o600, `mode 0${mode.toString(8)} != 0600`);
109
+ });
110
+ test("ScimStore: round-trip through disk (load after persist sees the entries)", async () => {
111
+ const path = tmpStore();
112
+ const a = new ScimStore(path);
113
+ await a.load();
114
+ await a.createUser({ userName: "alice@example.com" });
115
+ await a.createGroup({ displayName: "admins", members: [{ value: a.listUsers()[0].id }] });
116
+ const b = new ScimStore(path);
117
+ await b.load();
118
+ assert.equal(b.listUsers().length, 1);
119
+ assert.equal(b.listGroups().length, 1);
120
+ assert.equal(b.listUsers()[0].userName, "alice@example.com");
121
+ });
@@ -0,0 +1,73 @@
1
+ export declare const SCIM_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
2
+ export declare const SCIM_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
3
+ export declare const SCIM_SCHEMA_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
4
+ export declare const SCIM_SCHEMA_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
5
+ export declare const SCIM_SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
6
+ export interface ScimMeta {
7
+ resourceType: "User" | "Group";
8
+ created: string;
9
+ lastModified: string;
10
+ location?: string;
11
+ version?: string;
12
+ }
13
+ export interface ScimUser {
14
+ schemas: string[];
15
+ id: string;
16
+ userName: string;
17
+ active?: boolean;
18
+ displayName?: string;
19
+ name?: {
20
+ givenName?: string;
21
+ familyName?: string;
22
+ formatted?: string;
23
+ };
24
+ emails?: Array<{
25
+ value: string;
26
+ primary?: boolean;
27
+ type?: string;
28
+ }>;
29
+ /** SCIM `groups` is read-only — populated server-side from the
30
+ * group→members linkage. */
31
+ groups?: Array<{
32
+ value: string;
33
+ display?: string;
34
+ }>;
35
+ externalId?: string;
36
+ meta: ScimMeta;
37
+ }
38
+ export interface ScimGroup {
39
+ schemas: string[];
40
+ id: string;
41
+ displayName: string;
42
+ members?: Array<{
43
+ value: string;
44
+ display?: string;
45
+ type?: "User" | "Group";
46
+ }>;
47
+ externalId?: string;
48
+ meta: ScimMeta;
49
+ }
50
+ export interface ScimListResponse<T> {
51
+ schemas: string[];
52
+ totalResults: number;
53
+ Resources: T[];
54
+ startIndex?: number;
55
+ itemsPerPage?: number;
56
+ }
57
+ export interface ScimError {
58
+ schemas: string[];
59
+ status: string;
60
+ scimType?: string;
61
+ detail?: string;
62
+ }
63
+ export interface ScimPatchOperation {
64
+ op: "add" | "remove" | "replace";
65
+ path?: string;
66
+ value?: unknown;
67
+ }
68
+ export interface ScimPatchRequest {
69
+ schemas: string[];
70
+ Operations: ScimPatchOperation[];
71
+ }
72
+ export declare function scimError(status: number, detail: string, scimType?: string): ScimError;
73
+ export declare function nowIso(): string;
@@ -0,0 +1,29 @@
1
+ // SCIM 2.0 — minimal shared types.
2
+ //
3
+ // The gateway implements the subset of SCIM 2.0 that the most-common
4
+ // IdPs (Entra ID, Okta) use to provision Users and Groups. We do NOT
5
+ // aim for full RFC 7643 / 7644 compliance — only the methods the
6
+ // provisioning checklists exercise:
7
+ //
8
+ // Users: GET (list+by-id), POST, PATCH, DELETE
9
+ // Groups: GET (list+by-id), POST, PATCH, DELETE
10
+ // Discovery: ServiceProviderConfig, ResourceTypes, Schemas
11
+ //
12
+ // Other operations (PUT/replace, Bulk, search-via-POST) are deferred
13
+ // until an IdP customer explicitly requires them.
14
+ export const SCIM_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
15
+ export const SCIM_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
16
+ export const SCIM_SCHEMA_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
17
+ export const SCIM_SCHEMA_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
18
+ export const SCIM_SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
19
+ export function scimError(status, detail, scimType) {
20
+ return {
21
+ schemas: [SCIM_SCHEMA_ERROR],
22
+ status: String(status),
23
+ scimType,
24
+ detail,
25
+ };
26
+ }
27
+ export function nowIso() {
28
+ return new Date().toISOString();
29
+ }
@@ -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 {};