@thotischner/observability-mcp 3.0.0 → 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/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/auth/policy/batch-dry-run.js +15 -0
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +306 -65
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -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/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/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 +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- 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/index.d.ts +13 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -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/ui/index.html +856 -101
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -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.
|
|
@@ -22,11 +22,22 @@ export declare const detectAnomaliesDefinition: {
|
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
24
|
};
|
|
25
|
+
export interface AnomalyHistorySink {
|
|
26
|
+
record(entry: {
|
|
27
|
+
ts: string;
|
|
28
|
+
service: string;
|
|
29
|
+
tenant: string;
|
|
30
|
+
score: number;
|
|
31
|
+
method: string;
|
|
32
|
+
severity: string;
|
|
33
|
+
signal?: string;
|
|
34
|
+
}): Promise<void> | void;
|
|
35
|
+
}
|
|
25
36
|
export declare function detectAnomaliesHandler(registry: ConnectorRegistry, args: {
|
|
26
37
|
service?: string;
|
|
27
38
|
duration?: string;
|
|
28
39
|
sensitivity?: string;
|
|
29
|
-
}, ctx?: RequestContext): Promise<{
|
|
40
|
+
}, ctx?: RequestContext, history?: AnomalyHistorySink): Promise<{
|
|
30
41
|
content: {
|
|
31
42
|
type: "text";
|
|
32
43
|
text: string;
|
|
@@ -33,7 +33,7 @@ const KEY_METRICS = ["cpu", "memory", "error_rate", "latency_p99", "request_rate
|
|
|
33
33
|
// the overall error ratio is low (e.g. a memory leak emits a handful of
|
|
34
34
|
// "OutOfMemoryWarning" lines long before it turns into 5xx errors).
|
|
35
35
|
const CRITICAL_LOG_PATTERN = /\b(out\s?of\s?memory|oom|outofmemory|heap (usage|exhaust)|memory leak|panic|fatal|deadlock|segfault|stack overflow|cannot allocate)\b/i;
|
|
36
|
-
export async function detectAnomaliesHandler(registry, args, ctx = defaultContext()) {
|
|
36
|
+
export async function detectAnomaliesHandler(registry, args, ctx = defaultContext(), history) {
|
|
37
37
|
const duration = args.duration || "10m";
|
|
38
38
|
const threshold = SENSITIVITY_THRESHOLDS[args.sensitivity || "medium"] || 2.0;
|
|
39
39
|
// Discover services to scan — tenant-scoped.
|
|
@@ -72,9 +72,10 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
|
|
|
72
72
|
const deviationPercent = anomaly.baselineValue === 0
|
|
73
73
|
? 100
|
|
74
74
|
: Math.round(((anomaly.recentValue - anomaly.baselineValue) / anomaly.baselineValue) * 100);
|
|
75
|
+
const severityLabel = Math.abs(anomaly.score) >= 6 ? "high" : Math.abs(anomaly.score) >= 4 ? "medium" : "low";
|
|
75
76
|
allAnomalies.push({
|
|
76
77
|
metric,
|
|
77
|
-
severity:
|
|
78
|
+
severity: severityLabel,
|
|
78
79
|
description: `${metric}: ${anomaly.reason}`,
|
|
79
80
|
currentValue: anomaly.recentValue,
|
|
80
81
|
baselineValue: anomaly.baselineValue,
|
|
@@ -82,6 +83,25 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
|
|
|
82
83
|
source: connector.name,
|
|
83
84
|
service: serviceName,
|
|
84
85
|
});
|
|
86
|
+
// Phase P1: mirror the score to the TSDB sink (no-op if no
|
|
87
|
+
// sink wired). Best-effort — a slow / down sink must never
|
|
88
|
+
// block the detector loop, which is why we don't await.
|
|
89
|
+
if (history) {
|
|
90
|
+
try {
|
|
91
|
+
void history.record({
|
|
92
|
+
ts: new Date().toISOString(),
|
|
93
|
+
service: serviceName,
|
|
94
|
+
tenant: ctx.tenant || "default",
|
|
95
|
+
score: Math.abs(anomaly.score),
|
|
96
|
+
method: anomaly.method === "seasonal" ? "seasonality"
|
|
97
|
+
: anomaly.method === "robust-z" ? "mad"
|
|
98
|
+
: anomaly.method,
|
|
99
|
+
severity: severityLabel === "high" ? "critical" : severityLabel === "medium" ? "warn" : "info",
|
|
100
|
+
signal: metric,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch { /* swallow — best-effort */ }
|
|
104
|
+
}
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
catch {
|
package/dist/tools/topology.js
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
// connector later requires zero changes here.
|
|
15
15
|
import { isTopologyProvider } from "../connectors/interface.js";
|
|
16
16
|
import { defaultContext } from "../context.js";
|
|
17
|
+
import { mergeTopologies } from "../topology/merge.js";
|
|
17
18
|
export async function aggregateTopology(registry, tenant) {
|
|
18
19
|
const sources = [];
|
|
19
|
-
const
|
|
20
|
-
const edges = [];
|
|
20
|
+
const snapshots = [];
|
|
21
21
|
// Tenant-scoped when a tenant is supplied (call sites at the MCP
|
|
22
22
|
// tool layer pass ctx.tenant); undefined preserves the original
|
|
23
23
|
// global behaviour for internal / non-request callers.
|
|
@@ -34,14 +34,32 @@ export async function aggregateTopology(registry, tenant) {
|
|
|
34
34
|
resources: snap.resources.length,
|
|
35
35
|
edges: snap.edges.length,
|
|
36
36
|
});
|
|
37
|
-
|
|
38
|
-
edges.push(...snap.edges);
|
|
37
|
+
snapshots.push(snap);
|
|
39
38
|
}
|
|
40
39
|
catch {
|
|
41
40
|
// A misbehaving connector must not poison the agent's view of the graph.
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
|
-
|
|
43
|
+
// P1: run the snapshots through mergeTopologies so workloads
|
|
44
|
+
// surfaced by more than one provider (e.g. the same Deployment
|
|
45
|
+
// observed by both Kubernetes + a service-mesh connector) collapse
|
|
46
|
+
// into a single canonical node and edges are rewritten to match.
|
|
47
|
+
//
|
|
48
|
+
// ONLY engages for multi-source topologies — with a single snapshot
|
|
49
|
+
// the merger would mis-group intra-source siblings that happen to
|
|
50
|
+
// share a canonical label (e.g. two pod replicas with
|
|
51
|
+
// `app.kubernetes.io/name=api`). The merger is designed for
|
|
52
|
+
// cross-provider de-duplication, not intra-provider.
|
|
53
|
+
if (snapshots.length <= 1) {
|
|
54
|
+
const only = snapshots[0];
|
|
55
|
+
return {
|
|
56
|
+
sources,
|
|
57
|
+
resources: only?.resources ?? [],
|
|
58
|
+
edges: only?.edges ?? [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const merged = mergeTopologies(snapshots);
|
|
62
|
+
return { sources, resources: merged.resources, edges: merged.edges };
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
65
|
* Resolve a caller-supplied identifier to a Resource. Accepts:
|
|
@@ -208,3 +208,48 @@ describe("get_blast_radius tool", () => {
|
|
|
208
208
|
assert.equal(apiBucket.ownershipRootKind, "deployment");
|
|
209
209
|
});
|
|
210
210
|
});
|
|
211
|
+
// --- Multi-source merge (Phase P1 wiring) ----------------------------
|
|
212
|
+
// `aggregateTopology` now delegates to `mergeTopologies` when 2+
|
|
213
|
+
// snapshots are present so the same logical workload reported by
|
|
214
|
+
// e.g. Kubernetes + a cloud connector collapses into one node.
|
|
215
|
+
// Single-snapshot calls pass through unchanged (guarded so we don't
|
|
216
|
+
// mis-merge intra-source siblings that share an `app:` label).
|
|
217
|
+
describe("aggregateTopology — multi-source merger (P1 wire)", () => {
|
|
218
|
+
it("collapses cross-source duplicates that share a canonical label", async () => {
|
|
219
|
+
// Source A (k8s): one Deployment "checkout" in prod
|
|
220
|
+
const aRes = [
|
|
221
|
+
{ id: "k8s:deployment:prod/checkout", kind: "deployment", name: "checkout", source: "k8s",
|
|
222
|
+
labels: { "app.kubernetes.io/name": "checkout" } },
|
|
223
|
+
];
|
|
224
|
+
// Source B (trace provider): the same logical service
|
|
225
|
+
const bRes = [
|
|
226
|
+
{ id: "tempo:service:checkout", kind: "trace_service", name: "checkout", source: "tempo",
|
|
227
|
+
labels: { "service.name": "checkout" } },
|
|
228
|
+
];
|
|
229
|
+
const loader = new PluginLoader();
|
|
230
|
+
const reg = new ConnectorRegistry(loader);
|
|
231
|
+
const connA = new FakeTopologyConnector(aRes, []);
|
|
232
|
+
const connB = new FakeTopologyConnector(bRes, []);
|
|
233
|
+
await connA.connect({ name: "k8s", type: "fake", url: "", enabled: true });
|
|
234
|
+
await connB.connect({ name: "tempo", type: "fake", url: "", enabled: true });
|
|
235
|
+
const loaderInternal = loader;
|
|
236
|
+
loaderInternal.connectors.set("fake-a", { name: "fake-a", source: "builtin", factory: () => connA });
|
|
237
|
+
loaderInternal.connectors.set("fake-b", { name: "fake-b", source: "builtin", factory: () => connB });
|
|
238
|
+
await reg.addSource({ name: "k8s", type: "fake-a", url: "", enabled: true });
|
|
239
|
+
await reg.addSource({ name: "tempo", type: "fake-b", url: "", enabled: true });
|
|
240
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
241
|
+
// 2 sources reported in summary
|
|
242
|
+
assert.equal(out.sources.length, 2);
|
|
243
|
+
// But ONE resource after merge (deployment + trace_service of the
|
|
244
|
+
// same canonical name collapse via MERGEABLE_KIND_PAIRS).
|
|
245
|
+
assert.equal(out.resources.length, 1);
|
|
246
|
+
assert.equal(out.resources[0].name, "checkout");
|
|
247
|
+
});
|
|
248
|
+
it("single-source passes through unchanged (no intra-source merging)", async () => {
|
|
249
|
+
// The existing 4-pod fixture has two pods sharing `app: api`.
|
|
250
|
+
// With a single snapshot the merger must NOT collapse them.
|
|
251
|
+
const reg = await makeRegistry();
|
|
252
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
253
|
+
assert.equal(out.resources.length, fixture().resources.length);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SessionStore } from "./sessionStore.js";
|
|
2
|
+
export interface TransportSessionMeta {
|
|
3
|
+
/** Stable id of the replica that owns the underlying SDK
|
|
4
|
+
* Transport object. Set on creation. */
|
|
5
|
+
ownerReplica: string;
|
|
6
|
+
/** Optional virtual-server product slug. Undefined for the
|
|
7
|
+
* root /mcp surface. */
|
|
8
|
+
product?: string;
|
|
9
|
+
/** Epoch ms — bumped on every successful request. */
|
|
10
|
+
lastActive: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TransportSessionMap {
|
|
13
|
+
/** Stable backend identifier (used in /api/info diagnostics). */
|
|
14
|
+
readonly backend: string;
|
|
15
|
+
/** True iff there's a metadata entry — does NOT imply a local
|
|
16
|
+
* Transport exists. */
|
|
17
|
+
has(sessionId: string): Promise<boolean>;
|
|
18
|
+
get(sessionId: string): Promise<TransportSessionMeta | undefined>;
|
|
19
|
+
set(sessionId: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
|
|
20
|
+
/** Convenience: bump lastActive while preserving the rest. */
|
|
21
|
+
touch(sessionId: string, ttlSeconds?: number): Promise<void>;
|
|
22
|
+
delete(sessionId: string): Promise<void>;
|
|
23
|
+
/** Return every session id this map knows about. Used for the
|
|
24
|
+
* cleanup tick. Implementations MAY cap; in-memory returns the
|
|
25
|
+
* full set, Redis pages via SCAN under the prefix. */
|
|
26
|
+
keys(): Promise<string[]>;
|
|
27
|
+
/** Evict entries with lastActive older than `maxIdleMs`. Returns
|
|
28
|
+
* the evicted ids (caller logs / records metrics). */
|
|
29
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
30
|
+
}
|
|
31
|
+
export declare class InMemoryTransportSessionMap implements TransportSessionMap {
|
|
32
|
+
readonly backend = "memory";
|
|
33
|
+
private readonly map;
|
|
34
|
+
has(id: string): Promise<boolean>;
|
|
35
|
+
get(id: string): Promise<TransportSessionMeta | undefined>;
|
|
36
|
+
set(id: string, meta: TransportSessionMeta): Promise<void>;
|
|
37
|
+
touch(id: string): Promise<void>;
|
|
38
|
+
delete(id: string): Promise<void>;
|
|
39
|
+
keys(): Promise<string[]>;
|
|
40
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wraps an existing SessionStore so the per-session metadata lives
|
|
44
|
+
* wherever the SessionStore decided — InMemorySessionStore (no
|
|
45
|
+
* cross-replica visibility, identical to InMemoryTransportSessionMap
|
|
46
|
+
* but useful for tests / when a future backend is plugged in) or
|
|
47
|
+
* RedisSessionStore (cross-replica safe).
|
|
48
|
+
*
|
|
49
|
+
* Each entry stored at `<KEY_PREFIX><sessionId>` as JSON.
|
|
50
|
+
*/
|
|
51
|
+
export declare class SessionStoreBackedTransportSessionMap implements TransportSessionMap {
|
|
52
|
+
readonly backend: string;
|
|
53
|
+
private readonly store;
|
|
54
|
+
private readonly defaultTtlSeconds;
|
|
55
|
+
constructor(store: SessionStore, defaultTtlSeconds?: number);
|
|
56
|
+
has(id: string): Promise<boolean>;
|
|
57
|
+
get(id: string): Promise<TransportSessionMeta | undefined>;
|
|
58
|
+
set(id: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
|
|
59
|
+
touch(id: string, ttlSeconds?: number): Promise<void>;
|
|
60
|
+
delete(id: string): Promise<void>;
|
|
61
|
+
keys(): Promise<string[]>;
|
|
62
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pick the right implementation. When `sessionStore` is the
|
|
66
|
+
* in-memory default, return InMemoryTransportSessionMap so we
|
|
67
|
+
* avoid the (synchronous → async) layering tax. Otherwise wrap
|
|
68
|
+
* the supplied store.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createTransportSessionMap(sessionStore?: SessionStore): TransportSessionMap;
|