@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,128 @@
|
|
|
1
|
+
// Multi-replica-safe MCP transport session metadata.
|
|
2
|
+
//
|
|
3
|
+
// The actual SDK `StreamableHTTPServerTransport` object MUST live in
|
|
4
|
+
// the replica that originated the session — it owns the open HTTP
|
|
5
|
+
// response handles. What CAN be shared across replicas is the
|
|
6
|
+
// per-session metadata (last-active timestamp, virtual-server
|
|
7
|
+
// product slug, owner-replica id). This module promotes that map
|
|
8
|
+
// from a process-local `Map` to a small `TransportSessionMap` KV
|
|
9
|
+
// surface backed by either in-memory state (default) or the existing
|
|
10
|
+
// SessionStore Redis backend (opt-in via OMCP_REDIS_URL).
|
|
11
|
+
//
|
|
12
|
+
// Multi-replica behaviour:
|
|
13
|
+
// - Replica A creates session S. Writes metadata {ownerReplica:A,
|
|
14
|
+
// lastActive, product} to the shared map.
|
|
15
|
+
// - Subsequent request lands on Replica B with the same S header.
|
|
16
|
+
// B consults the shared map, sees ownerReplica:A, replies 410
|
|
17
|
+
// so the load balancer rehashes or the client retries.
|
|
18
|
+
// - This keeps the gateway functional even when sticky ingress
|
|
19
|
+
// drops the affinity — the worst case is a single 410 retry,
|
|
20
|
+
// not a silent NEW transport (which today races against the
|
|
21
|
+
// real one).
|
|
22
|
+
//
|
|
23
|
+
// The TTL on each entry mirrors SESSION_TTL_MS so the map doesn't
|
|
24
|
+
// grow unbounded if a replica disappears without graceful
|
|
25
|
+
// shutdown — TTL-based eviction is the safety net.
|
|
26
|
+
// ---------------------------------------------------------------- in-memory
|
|
27
|
+
export class InMemoryTransportSessionMap {
|
|
28
|
+
backend = "memory";
|
|
29
|
+
map = new Map();
|
|
30
|
+
async has(id) { return this.map.has(id); }
|
|
31
|
+
async get(id) {
|
|
32
|
+
return this.map.get(id);
|
|
33
|
+
}
|
|
34
|
+
async set(id, meta) {
|
|
35
|
+
this.map.set(id, meta);
|
|
36
|
+
}
|
|
37
|
+
async touch(id) {
|
|
38
|
+
const cur = this.map.get(id);
|
|
39
|
+
if (!cur)
|
|
40
|
+
return;
|
|
41
|
+
cur.lastActive = Date.now();
|
|
42
|
+
}
|
|
43
|
+
async delete(id) { this.map.delete(id); }
|
|
44
|
+
async keys() { return [...this.map.keys()]; }
|
|
45
|
+
async cleanup(maxIdleMs) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const evicted = [];
|
|
48
|
+
for (const [id, meta] of this.map) {
|
|
49
|
+
if (now - meta.lastActive > maxIdleMs) {
|
|
50
|
+
this.map.delete(id);
|
|
51
|
+
evicted.push(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return evicted;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------- SessionStore-backed
|
|
58
|
+
const KEY_PREFIX = "transport:";
|
|
59
|
+
/**
|
|
60
|
+
* Wraps an existing SessionStore so the per-session metadata lives
|
|
61
|
+
* wherever the SessionStore decided — InMemorySessionStore (no
|
|
62
|
+
* cross-replica visibility, identical to InMemoryTransportSessionMap
|
|
63
|
+
* but useful for tests / when a future backend is plugged in) or
|
|
64
|
+
* RedisSessionStore (cross-replica safe).
|
|
65
|
+
*
|
|
66
|
+
* Each entry stored at `<KEY_PREFIX><sessionId>` as JSON.
|
|
67
|
+
*/
|
|
68
|
+
export class SessionStoreBackedTransportSessionMap {
|
|
69
|
+
backend;
|
|
70
|
+
store;
|
|
71
|
+
defaultTtlSeconds;
|
|
72
|
+
constructor(store, defaultTtlSeconds = 30 * 60) {
|
|
73
|
+
this.store = store;
|
|
74
|
+
this.backend = `session-store:${store.backend}`;
|
|
75
|
+
this.defaultTtlSeconds = defaultTtlSeconds;
|
|
76
|
+
}
|
|
77
|
+
async has(id) {
|
|
78
|
+
return (await this.store.get(KEY_PREFIX + id)) !== undefined;
|
|
79
|
+
}
|
|
80
|
+
async get(id) {
|
|
81
|
+
return (await this.store.get(KEY_PREFIX + id)) ?? undefined;
|
|
82
|
+
}
|
|
83
|
+
async set(id, meta, ttlSeconds) {
|
|
84
|
+
await this.store.setEx(KEY_PREFIX + id, ttlSeconds ?? this.defaultTtlSeconds, meta);
|
|
85
|
+
}
|
|
86
|
+
async touch(id, ttlSeconds) {
|
|
87
|
+
const cur = await this.get(id);
|
|
88
|
+
if (!cur)
|
|
89
|
+
return;
|
|
90
|
+
cur.lastActive = Date.now();
|
|
91
|
+
await this.set(id, cur, ttlSeconds);
|
|
92
|
+
}
|
|
93
|
+
async delete(id) {
|
|
94
|
+
await this.store.del(KEY_PREFIX + id);
|
|
95
|
+
}
|
|
96
|
+
async keys() {
|
|
97
|
+
const raw = await this.store.keys(KEY_PREFIX);
|
|
98
|
+
return raw.map((k) => k.slice(KEY_PREFIX.length));
|
|
99
|
+
}
|
|
100
|
+
async cleanup(maxIdleMs) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const ids = await this.keys();
|
|
103
|
+
const evicted = [];
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
const meta = await this.get(id);
|
|
106
|
+
if (!meta)
|
|
107
|
+
continue;
|
|
108
|
+
if (now - meta.lastActive > maxIdleMs) {
|
|
109
|
+
await this.delete(id);
|
|
110
|
+
evicted.push(id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return evicted;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------- factory
|
|
117
|
+
/**
|
|
118
|
+
* Pick the right implementation. When `sessionStore` is the
|
|
119
|
+
* in-memory default, return InMemoryTransportSessionMap so we
|
|
120
|
+
* avoid the (synchronous → async) layering tax. Otherwise wrap
|
|
121
|
+
* the supplied store.
|
|
122
|
+
*/
|
|
123
|
+
export function createTransportSessionMap(sessionStore) {
|
|
124
|
+
if (!sessionStore || sessionStore.backend === "memory") {
|
|
125
|
+
return new InMemoryTransportSessionMap();
|
|
126
|
+
}
|
|
127
|
+
return new SessionStoreBackedTransportSessionMap(sessionStore);
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { InMemoryTransportSessionMap, SessionStoreBackedTransportSessionMap, createTransportSessionMap, } from "./transportSessionMap.js";
|
|
4
|
+
import { InMemorySessionStore } from "./sessionStore.js";
|
|
5
|
+
function meta(p = {}) {
|
|
6
|
+
return {
|
|
7
|
+
ownerReplica: p.ownerReplica ?? "replica-A",
|
|
8
|
+
product: p.product,
|
|
9
|
+
lastActive: p.lastActive ?? Date.now(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
test("InMemoryTransportSessionMap: get/set/has/delete round-trip", async () => {
|
|
13
|
+
const m = new InMemoryTransportSessionMap();
|
|
14
|
+
assert.equal(await m.has("s1"), false);
|
|
15
|
+
await m.set("s1", meta({ product: "checkout" }));
|
|
16
|
+
assert.equal(await m.has("s1"), true);
|
|
17
|
+
const got = await m.get("s1");
|
|
18
|
+
assert.equal(got?.ownerReplica, "replica-A");
|
|
19
|
+
assert.equal(got?.product, "checkout");
|
|
20
|
+
await m.delete("s1");
|
|
21
|
+
assert.equal(await m.has("s1"), false);
|
|
22
|
+
});
|
|
23
|
+
test("InMemoryTransportSessionMap: touch bumps lastActive", async () => {
|
|
24
|
+
const m = new InMemoryTransportSessionMap();
|
|
25
|
+
const before = Date.now() - 10_000;
|
|
26
|
+
await m.set("s1", meta({ lastActive: before }));
|
|
27
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
28
|
+
await m.touch("s1");
|
|
29
|
+
const got = await m.get("s1");
|
|
30
|
+
assert.ok((got?.lastActive ?? 0) > before);
|
|
31
|
+
});
|
|
32
|
+
test("InMemoryTransportSessionMap: keys + cleanup", async () => {
|
|
33
|
+
const m = new InMemoryTransportSessionMap();
|
|
34
|
+
await m.set("fresh", meta({ lastActive: Date.now() }));
|
|
35
|
+
await m.set("stale", meta({ lastActive: Date.now() - 10_000 }));
|
|
36
|
+
const all = await m.keys();
|
|
37
|
+
assert.equal(all.length, 2);
|
|
38
|
+
const evicted = await m.cleanup(5_000);
|
|
39
|
+
assert.deepEqual(evicted, ["stale"]);
|
|
40
|
+
assert.equal(await m.has("stale"), false);
|
|
41
|
+
assert.equal(await m.has("fresh"), true);
|
|
42
|
+
});
|
|
43
|
+
test("SessionStoreBackedTransportSessionMap: round-trip via SessionStore", async () => {
|
|
44
|
+
const store = new InMemorySessionStore();
|
|
45
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
46
|
+
await m.set("s1", meta({ product: "p" }));
|
|
47
|
+
assert.equal(await m.has("s1"), true);
|
|
48
|
+
const got = await m.get("s1");
|
|
49
|
+
assert.equal(got?.product, "p");
|
|
50
|
+
await m.delete("s1");
|
|
51
|
+
assert.equal(await m.has("s1"), false);
|
|
52
|
+
});
|
|
53
|
+
test("SessionStoreBackedTransportSessionMap: keys excludes the prefix in returned ids", async () => {
|
|
54
|
+
const store = new InMemorySessionStore();
|
|
55
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
56
|
+
await m.set("alpha", meta());
|
|
57
|
+
await m.set("beta", meta());
|
|
58
|
+
const ids = (await m.keys()).sort();
|
|
59
|
+
assert.deepEqual(ids, ["alpha", "beta"]);
|
|
60
|
+
// Unrelated keys on the same SessionStore must not pollute the map.
|
|
61
|
+
await store.set("scim:user:1", { foo: "bar" });
|
|
62
|
+
const idsAfter = (await m.keys()).sort();
|
|
63
|
+
assert.deepEqual(idsAfter, ["alpha", "beta"]);
|
|
64
|
+
});
|
|
65
|
+
test("SessionStoreBackedTransportSessionMap: cleanup evicts stale entries", async () => {
|
|
66
|
+
const store = new InMemorySessionStore();
|
|
67
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
68
|
+
await m.set("fresh", meta({ lastActive: Date.now() }));
|
|
69
|
+
await m.set("stale", meta({ lastActive: Date.now() - 60_000 }));
|
|
70
|
+
const evicted = await m.cleanup(30_000);
|
|
71
|
+
assert.deepEqual(evicted, ["stale"]);
|
|
72
|
+
});
|
|
73
|
+
test("SessionStoreBackedTransportSessionMap: touch is no-op on missing id", async () => {
|
|
74
|
+
const store = new InMemorySessionStore();
|
|
75
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
76
|
+
await m.touch("ghost");
|
|
77
|
+
assert.equal(await m.has("ghost"), false);
|
|
78
|
+
});
|
|
79
|
+
test("SessionStoreBackedTransportSessionMap: backend tag exposes underlying store name", () => {
|
|
80
|
+
const store = new InMemorySessionStore();
|
|
81
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
82
|
+
assert.equal(m.backend, "session-store:memory");
|
|
83
|
+
});
|
|
84
|
+
test("createTransportSessionMap: memory backend returns in-memory impl", () => {
|
|
85
|
+
const store = new InMemorySessionStore();
|
|
86
|
+
const m = createTransportSessionMap(store);
|
|
87
|
+
assert.equal(m.backend, "memory");
|
|
88
|
+
});
|
|
89
|
+
test("createTransportSessionMap: non-memory backend returns wrapper", () => {
|
|
90
|
+
// Fake a non-memory backend by stubbing the backend tag.
|
|
91
|
+
const fake = {
|
|
92
|
+
backend: "redis",
|
|
93
|
+
async get() { return undefined; },
|
|
94
|
+
async set() { },
|
|
95
|
+
async setEx() { },
|
|
96
|
+
async del() { },
|
|
97
|
+
async keys() { return []; },
|
|
98
|
+
async close() { },
|
|
99
|
+
};
|
|
100
|
+
const m = createTransportSessionMap(fake);
|
|
101
|
+
assert.equal(m.backend, "session-store:redis");
|
|
102
|
+
});
|
|
103
|
+
test("createTransportSessionMap: undefined sessionStore → memory impl (back-compat)", () => {
|
|
104
|
+
const m = createTransportSessionMap();
|
|
105
|
+
assert.equal(m.backend, "memory");
|
|
106
|
+
});
|
|
107
|
+
test("InMemoryTransportSessionMap.touch: ghost id is no-op", async () => {
|
|
108
|
+
const m = new InMemoryTransportSessionMap();
|
|
109
|
+
await m.touch("ghost");
|
|
110
|
+
assert.equal(await m.has("ghost"), false);
|
|
111
|
+
});
|