@thotischner/observability-mcp 1.8.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +15 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +33 -11
- package/dist/tools/topology.test.js +45 -0
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +2529 -145
- package/package.json +13 -3
|
@@ -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,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
|
+
});
|
|
@@ -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
|
+
}
|