@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.
Files changed (53) hide show
  1. package/dist/audit/sinks/s3.d.ts +61 -0
  2. package/dist/audit/sinks/s3.js +179 -0
  3. package/dist/audit/sinks/s3.test.d.ts +1 -0
  4. package/dist/audit/sinks/s3.test.js +175 -0
  5. package/dist/auth/policy/batch-dry-run.js +15 -0
  6. package/dist/connectors/loader.d.ts +8 -0
  7. package/dist/connectors/loader.js +49 -0
  8. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  9. package/dist/connectors/manifest-hooks.test.js +206 -0
  10. package/dist/federation/registry.d.ts +27 -5
  11. package/dist/federation/registry.js +49 -4
  12. package/dist/federation/registry.test.js +79 -3
  13. package/dist/federation/upstream.d.ts +32 -6
  14. package/dist/federation/upstream.js +60 -12
  15. package/dist/federation/upstream.test.d.ts +1 -0
  16. package/dist/federation/upstream.test.js +118 -0
  17. package/dist/index.js +306 -65
  18. package/dist/metrics/self.d.ts +1 -0
  19. package/dist/metrics/self.js +8 -0
  20. package/dist/policy/redact.js +1 -1
  21. package/dist/postmortem/store.d.ts +34 -0
  22. package/dist/postmortem/store.js +113 -0
  23. package/dist/postmortem/store.test.d.ts +1 -0
  24. package/dist/postmortem/store.test.js +118 -0
  25. package/dist/scim/compliance.test.d.ts +1 -0
  26. package/dist/scim/compliance.test.js +169 -0
  27. package/dist/scim/factory.test.d.ts +1 -0
  28. package/dist/scim/factory.test.js +54 -0
  29. package/dist/scim/patch-ops.test.d.ts +1 -0
  30. package/dist/scim/patch-ops.test.js +100 -0
  31. package/dist/scim/redis-store.d.ts +38 -0
  32. package/dist/scim/redis-store.js +178 -0
  33. package/dist/scim/redis-store.test.d.ts +1 -0
  34. package/dist/scim/redis-store.test.js +138 -0
  35. package/dist/scim/routes.d.ts +27 -2
  36. package/dist/scim/routes.js +161 -15
  37. package/dist/scim/store.d.ts +40 -1
  38. package/dist/scim/store.js +23 -5
  39. package/dist/sdk/hook-wrappers.d.ts +39 -0
  40. package/dist/sdk/hook-wrappers.js +113 -0
  41. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  42. package/dist/sdk/hook-wrappers.test.js +204 -0
  43. package/dist/sdk/index.d.ts +13 -0
  44. package/dist/tools/detect-anomalies.d.ts +12 -1
  45. package/dist/tools/detect-anomalies.js +22 -2
  46. package/dist/tools/topology.js +23 -5
  47. package/dist/tools/topology.test.js +45 -0
  48. package/dist/transport/transportSessionMap.d.ts +70 -0
  49. package/dist/transport/transportSessionMap.js +128 -0
  50. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  51. package/dist/transport/transportSessionMap.test.js +111 -0
  52. package/dist/ui/index.html +856 -101
  53. 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
+ });