@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.
Files changed (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,138 @@
1
+ // Shared session store — abstract backend for any short-lived state
2
+ // the gateway needs to keep across replicas: MCP Streamable HTTP
3
+ // session metadata, OIDC flow state (PKCE verifier, nonce, redirect
4
+ // target), DCR-registered client metadata, federation upstream
5
+ // catalogue cache.
6
+ //
7
+ // Two implementations ship in v2.0:
8
+ //
9
+ // InMemorySessionStore — the default; preserves the pre-F8 behaviour
10
+ // (single-replica, ephemeral on restart). No new dep, no new env.
11
+ //
12
+ // RedisSessionStore — opt-in via OMCP_REDIS_URL. Backs all consumers
13
+ // with a shared Redis so multi-replica deployments stop losing
14
+ // sessions on rollouts and so federated upstreams can share a
15
+ // cache. Driver loaded via dynamic import so the `redis` package
16
+ // only loads when the store is configured.
17
+ //
18
+ // Consumers MUST treat returns as eventually-consistent across
19
+ // replicas: a get() right after a set() on a different replica may
20
+ // return undefined while replication catches up. Use TTLs for any
21
+ // state that must self-cleanup (the store's `setEx` honours the
22
+ // requested ttl seconds; `set` is no-expiry).
23
+ /** Single-process, in-memory store. Default when OMCP_REDIS_URL is
24
+ * unset. Lazy expiry — entries are evicted on the next access. */
25
+ export class InMemorySessionStore {
26
+ backend = "memory";
27
+ map = new Map();
28
+ async get(key) {
29
+ const entry = this.map.get(key);
30
+ if (!entry)
31
+ return undefined;
32
+ if (entry.expiresAt <= Date.now()) {
33
+ this.map.delete(key);
34
+ return undefined;
35
+ }
36
+ return entry.value;
37
+ }
38
+ async set(key, value) {
39
+ this.map.set(key, { value, expiresAt: Number.POSITIVE_INFINITY });
40
+ }
41
+ async setEx(key, ttlSeconds, value) {
42
+ this.map.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
43
+ }
44
+ async del(key) {
45
+ this.map.delete(key);
46
+ }
47
+ async keys(prefix) {
48
+ const out = [];
49
+ const now = Date.now();
50
+ for (const [k, v] of this.map) {
51
+ if (v.expiresAt <= now) {
52
+ this.map.delete(k);
53
+ continue;
54
+ }
55
+ if (k.startsWith(prefix))
56
+ out.push(k);
57
+ }
58
+ return out;
59
+ }
60
+ async close() {
61
+ this.map.clear();
62
+ }
63
+ /** Test-only: introspect size. */
64
+ size() {
65
+ return this.map.size;
66
+ }
67
+ }
68
+ /** Redis-backed store. Constructed lazily via `connectRedisStore` so
69
+ * the `redis` driver only loads when actually used. */
70
+ export class RedisSessionStore {
71
+ backend = "redis";
72
+ client;
73
+ prefix;
74
+ constructor(client, prefix = "omcp:") {
75
+ this.client = client;
76
+ this.prefix = prefix;
77
+ }
78
+ k(key) {
79
+ return `${this.prefix}${key}`;
80
+ }
81
+ async get(key) {
82
+ const raw = await this.client.get(this.k(key));
83
+ if (raw == null)
84
+ return undefined;
85
+ try {
86
+ return JSON.parse(raw);
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ async set(key, value) {
93
+ await this.client.set(this.k(key), JSON.stringify(value));
94
+ }
95
+ async setEx(key, ttlSeconds, value) {
96
+ await this.client.set(this.k(key), JSON.stringify(value), {
97
+ EX: ttlSeconds,
98
+ });
99
+ }
100
+ async del(key) {
101
+ await this.client.del(this.k(key));
102
+ }
103
+ async keys(prefix) {
104
+ const found = await this.client.keys(`${this.k(prefix)}*`);
105
+ return found.map((k) => k.slice(this.prefix.length));
106
+ }
107
+ async close() {
108
+ try {
109
+ await this.client.quit();
110
+ }
111
+ catch {
112
+ /* socket may already be down */
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Resolve the session store from env. Default: InMemorySessionStore.
118
+ * When OMCP_REDIS_URL is set: load `redis` dynamically, connect, and
119
+ * return a RedisSessionStore. On any connect failure, log + fall back
120
+ * to InMemory (the gateway must boot even when Redis is down).
121
+ */
122
+ export async function resolveSessionStore(env = process.env) {
123
+ const url = env.OMCP_REDIS_URL?.trim();
124
+ if (!url)
125
+ return new InMemorySessionStore();
126
+ try {
127
+ const { createClient } = await import("redis");
128
+ const client = createClient({ url });
129
+ client.on("error", (err) => console.warn("RedisSessionStore: client error: %s", err instanceof Error ? err.message : String(err)));
130
+ await client.connect();
131
+ console.log("RedisSessionStore: connected (url scheme=%s, prefix=%s)", new URL(url).protocol.replace(/:$/, ""), env.OMCP_REDIS_KEY_PREFIX ?? "omcp:");
132
+ return new RedisSessionStore(client, env.OMCP_REDIS_KEY_PREFIX ?? "omcp:");
133
+ }
134
+ catch (err) {
135
+ console.warn("RedisSessionStore: connect failed, falling back to in-memory store: %s", err instanceof Error ? err.message : String(err));
136
+ return new InMemorySessionStore();
137
+ }
138
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { InMemorySessionStore, RedisSessionStore, } from "./sessionStore.js";
4
+ test("InMemorySessionStore: backend identifier is 'memory'", () => {
5
+ const s = new InMemorySessionStore();
6
+ assert.equal(s.backend, "memory");
7
+ });
8
+ test("InMemorySessionStore: set + get round-trips JSON-serialisable values", async () => {
9
+ const s = new InMemorySessionStore();
10
+ await s.set("k", { a: 1, b: ["x", "y"] });
11
+ assert.deepEqual(await s.get("k"), { a: 1, b: ["x", "y"] });
12
+ });
13
+ test("InMemorySessionStore: missing key returns undefined", async () => {
14
+ const s = new InMemorySessionStore();
15
+ assert.equal(await s.get("nope"), undefined);
16
+ });
17
+ test("InMemorySessionStore: del removes a key", async () => {
18
+ const s = new InMemorySessionStore();
19
+ await s.set("k", "v");
20
+ await s.del("k");
21
+ assert.equal(await s.get("k"), undefined);
22
+ });
23
+ test("InMemorySessionStore: setEx expires after ttl", async () => {
24
+ const s = new InMemorySessionStore();
25
+ await s.setEx("k", 0.05, "soon-gone"); // 50ms
26
+ assert.equal(await s.get("k"), "soon-gone");
27
+ await new Promise((r) => setTimeout(r, 80));
28
+ assert.equal(await s.get("k"), undefined);
29
+ });
30
+ test("InMemorySessionStore: keys(prefix) returns matching keys, drops expired ones", async () => {
31
+ const s = new InMemorySessionStore();
32
+ await s.set("oidc:flow:a", 1);
33
+ await s.set("oidc:flow:b", 2);
34
+ await s.set("mcp:session:c", 3);
35
+ await s.setEx("oidc:flow:expired", 0.02, 4);
36
+ await new Promise((r) => setTimeout(r, 50));
37
+ const oidc = await s.keys("oidc:flow:");
38
+ assert.deepEqual(oidc.sort(), ["oidc:flow:a", "oidc:flow:b"]);
39
+ assert.deepEqual(await s.keys("mcp:"), ["mcp:session:c"]);
40
+ });
41
+ test("InMemorySessionStore: close() clears state", async () => {
42
+ const s = new InMemorySessionStore();
43
+ await s.set("k", 1);
44
+ await s.close();
45
+ assert.equal(await s.get("k"), undefined);
46
+ assert.equal(s.size(), 0);
47
+ });
48
+ // FakeRedis: tiny in-memory implementation of the RedisClientLike
49
+ // surface, lets us test RedisSessionStore without a real broker.
50
+ class FakeRedis {
51
+ store = new Map();
52
+ quitCalled = false;
53
+ async get(key) {
54
+ return this.store.get(key) ?? null;
55
+ }
56
+ async set(key, value, _opts) {
57
+ this.store.set(key, value);
58
+ return "OK";
59
+ }
60
+ async del(key) {
61
+ return this.store.delete(key) ? 1 : 0;
62
+ }
63
+ async keys(pattern) {
64
+ // FakeRedis only supports prefix*.
65
+ const prefix = pattern.replace(/\*$/, "");
66
+ return [...this.store.keys()].filter((k) => k.startsWith(prefix));
67
+ }
68
+ async quit() {
69
+ this.quitCalled = true;
70
+ return "OK";
71
+ }
72
+ }
73
+ test("RedisSessionStore: applies prefix on set/get/del/keys", async () => {
74
+ const fake = new FakeRedis();
75
+ const s = new RedisSessionStore(fake, "test:");
76
+ await s.set("k", { hello: "world" });
77
+ assert.ok(fake.store.has("test:k"));
78
+ assert.deepEqual(await s.get("k"), { hello: "world" });
79
+ await s.del("k");
80
+ assert.equal(await s.get("k"), undefined);
81
+ });
82
+ test("RedisSessionStore: keys() strips the prefix before returning", async () => {
83
+ const fake = new FakeRedis();
84
+ const s = new RedisSessionStore(fake, "test:");
85
+ await s.set("oidc:a", 1);
86
+ await s.set("oidc:b", 2);
87
+ const found = await s.keys("oidc:");
88
+ assert.deepEqual(found.sort(), ["oidc:a", "oidc:b"]);
89
+ });
90
+ test("RedisSessionStore: setEx forwards EX to the driver", async () => {
91
+ const fake = new FakeRedis();
92
+ let seenOpts;
93
+ fake.set = async (key, value, opts) => {
94
+ seenOpts = opts;
95
+ fake.store.set(key, value);
96
+ return "OK";
97
+ };
98
+ const s = new RedisSessionStore(fake, "");
99
+ await s.setEx("k", 30, "v");
100
+ assert.deepEqual(seenOpts, { EX: 30 });
101
+ });
102
+ test("RedisSessionStore: malformed JSON in store returns undefined", async () => {
103
+ const fake = new FakeRedis();
104
+ fake.store.set("test:bad", "not-json{");
105
+ const s = new RedisSessionStore(fake, "test:");
106
+ assert.equal(await s.get("bad"), undefined);
107
+ });
108
+ test("RedisSessionStore: close() calls quit()", async () => {
109
+ const fake = new FakeRedis();
110
+ const s = new RedisSessionStore(fake, "test:");
111
+ await s.close();
112
+ assert.equal(fake.quitCalled, true);
113
+ });
114
+ test("RedisSessionStore: backend identifier is 'redis'", () => {
115
+ const fake = new FakeRedis();
116
+ const s = new RedisSessionStore(fake);
117
+ assert.equal(s.backend, "redis");
118
+ });
@@ -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;
@@ -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
+ });
@@ -0,0 +1,35 @@
1
+ import type { WebSocket } from "ws";
2
+ import type { JSONRPCMessage, MessageExtraInfo, RequestId } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ export declare const PING_INTERVAL_MS = 30000;
5
+ export declare const PING_TIMEOUT_MS = 90000;
6
+ export interface WebSocketTransportOptions {
7
+ /** Override the generated session id (useful for tests). */
8
+ sessionId?: string;
9
+ /** Override heartbeat ping interval; default 30s. */
10
+ pingIntervalMs?: number;
11
+ /** Override stale-connection timeout; default 90s. */
12
+ pingTimeoutMs?: number;
13
+ }
14
+ /**
15
+ * Per-WS-connection MCP transport. One instance per accepted socket.
16
+ */
17
+ export declare class WebSocketServerTransport implements Transport {
18
+ sessionId?: string;
19
+ onclose?: () => void;
20
+ onerror?: (error: Error) => void;
21
+ onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
22
+ private ws;
23
+ private pingTimer?;
24
+ private lastPongAt;
25
+ private pingIntervalMs;
26
+ private pingTimeoutMs;
27
+ private closed;
28
+ constructor(ws: WebSocket, opts?: WebSocketTransportOptions);
29
+ start(): Promise<void>;
30
+ send(message: JSONRPCMessage, _options?: {
31
+ relatedRequestId?: RequestId;
32
+ }): Promise<void>;
33
+ close(): Promise<void>;
34
+ private handleClose;
35
+ }