@cyanmycelium/mcp-broker 1.3.1 → 1.3.2

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.
@@ -4,7 +4,7 @@
4
4
  "description": "Get the broker's identity: name, version, uptime, host, port, TLS, and URL paths. Call this first when you need to know what you are talking to."
5
5
  },
6
6
  "providers_list": {
7
- "description": "List every provider slot on this broker, connected or not. Each entry tells you the transport kind, current client count, and how many requests are in flight. Use this to discover what you can route to."
7
+ "description": "List every provider slot on this broker, connected or not. Each entry tells you the transport kind, whether the provider is in `_all`, when it attached, the current client count, and how many requests are in flight. The counts are the slot's callers, not the provider: a connected provider nobody is calling reads all zeros. Use this to discover what you can route to."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Get the full status of one provider slot by name. Returns an error if the slot does not exist, start with providers_list if you are not sure of the name.",
@@ -4,7 +4,7 @@
4
4
  "description": "Récupère l'identité du broker : nom, version, uptime, hôte, port, TLS et chemins URL. À appeler en premier pour savoir à qui tu parles."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Liste tous les emplacements de providers de ce broker, connectés ou non. Chaque entrée indique le type de transport, le nombre de clients actuels et le nombre de requêtes en cours. Utilise-le pour découvrir vers qui tu peux router."
7
+ "description": "Liste tous les emplacements de providers de ce broker, connectés ou non. Chaque entrée indique le type de transport, l'appartenance à `_all`, la date de connexion du provider, le nombre de clients actuels et le nombre de requêtes en cours. Les compteurs sont ceux des appelants du slot, pas du provider : un provider connecté que personne n'appelle affiche des zéros. Utilise-le pour découvrir vers qui tu peux router."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Récupère le statut complet d'un emplacement de provider par son nom. Retourne une erreur si l'emplacement n'existe pas, commence par providers_list si tu n'es pas sûr du nom.",
@@ -4,7 +4,7 @@
4
4
  "description": "Returns the broker's name, version, uptime, host, port, TLS status, and configured URL paths."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Returns every provider slot known to the broker (connected and disconnected), with transport kind, client count, and pending request count."
7
+ "description": "Returns every provider slot known to the broker (connected and disconnected), with transport kind, `_all` membership, when the provider attached, client count, and pending request count. The counts are the slot's callers, not the provider: a connected provider nobody is calling reads all zeros."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Returns the status of one provider slot identified by name. Errors if the slot does not exist.",
@@ -4,7 +4,7 @@
4
4
  "description": "Retourne le nom, la version, l'uptime, l'hôte, le port, l'état TLS et les chemins URL du broker."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Liste tous les emplacements de providers connus du broker (connectés et déconnectés), avec leur type de transport, leur nombre de clients et le nombre de requêtes en attente."
7
+ "description": "Liste tous les emplacements de providers connus du broker (connectés et déconnectés), avec leur type de transport, leur appartenance à `_all`, la date de connexion du provider, leur nombre de clients et le nombre de requêtes en attente. Les compteurs sont ceux des appelants du slot, pas du provider : un provider connecté que personne n'appelle affiche des zéros."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Retourne l'état d'un emplacement de provider identifié par son nom. Échoue si le provider n'existe pas.",
@@ -4,7 +4,7 @@
4
4
  "description": "返回 broker 的名称、版本、运行时间、主机、端口、TLS 状态和已配置的 URL 路径。"
5
5
  },
6
6
  "providers_list": {
7
- "description": "列出 broker 已知的所有 provider 槽位(已连接和未连接),包括传输类型、客户端数量和待处理请求数量。"
7
+ "description": "列出 broker 已知的所有 provider 槽位(已连接和未连接),包括传输类型、是否属于 `_all`、provider 的接入时间、客户端数量和待处理请求数量。计数针对槽位的调用方而非 provider:已连接但无人调用的 provider 显示全零。"
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "返回由名称标识的某个 provider 槽位的状态。如果槽位不存在则返回错误。",
package/dist/index.d.ts CHANGED
@@ -98,7 +98,26 @@ interface IBrokerProviderInfo {
98
98
  transport: BrokerProviderTransport;
99
99
  /** `true` iff the slot is reachable for routing right now. */
100
100
  connected: boolean;
101
- /** Number of raw-WebSocket MCP clients on this slot. */
101
+ /**
102
+ * `true` when the provider is a member of the `_all` aggregate right now:
103
+ * it opted in and its `initialize` went through. The other side of
104
+ * {@link IBrokerAggregateInfo.providers}, per slot.
105
+ */
106
+ aggregate: boolean;
107
+ /**
108
+ * When the provider now serving the slot attached, ISO-8601; `null`
109
+ * while nothing serves it. Survives nothing: a reconnection is a new
110
+ * date, which is the point (a slot that says "since 3 s ago" every time
111
+ * you look is flapping).
112
+ */
113
+ connectedSince: string | null;
114
+ /** Milliseconds since `connectedSince`, computed at the time of the call; `null` when disconnected. */
115
+ connectedForMs: number | null;
116
+ /**
117
+ * Number of raw-WebSocket MCP clients on this slot. Clients, not the
118
+ * provider: a connected provider nobody is calling reads
119
+ * `clientCount: 0, sessionCount: 0, pendingCount: 0`.
120
+ */
102
121
  clientCount: number;
103
122
  /** Number of long-lived sessions (SSE + Streamable HTTP GET streams). */
104
123
  sessionCount: number;
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { AuthError, BROKER_AGGREGATE_NAME, BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, BROKER_GUIDE_URI_PREFIX, BROKER_GUIDE_URI_TEMPLATE, BROKER_PROVIDER_NAME, BROKER_RESERVED_SLOTS, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, BrokerGuideAdapter, BrokerGuideBehavior, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, SharedSecretProviderAuthenticator, StdioUpstream, SubjectMappingError, VERSION, WsTunnel, WsTunnelBuilder, authorizationWithEngine, brokerGrammarKey, brokerGuide, brokerGuideIndex, brokerGuideTopicFromUri, brokerGuideUri, buildJwtAuth, buildResourceMetadata, compileAuthorizationPolicy, compileProviderAllowedResources, diagnoseBroker, hasAuthorizationPolicies, isReservedBrokerSlot, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerConfig, loadBrokerGrammar, loadMcpbBundle, normalizeProviderAuthentication, providerMayPublish, providerPublishDecision, resolveOpenTarget, scopesOf, startBrokerServer, unzipMcpb, validateCapability } from './chunk-UOJRDF5R.js';
1
+ export { AuthError, BROKER_AGGREGATE_NAME, BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, BROKER_GUIDE_URI_PREFIX, BROKER_GUIDE_URI_TEMPLATE, BROKER_PROVIDER_NAME, BROKER_RESERVED_SLOTS, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, BrokerGuideAdapter, BrokerGuideBehavior, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, SharedSecretProviderAuthenticator, StdioUpstream, SubjectMappingError, VERSION, WsTunnel, WsTunnelBuilder, authorizationWithEngine, brokerGrammarKey, brokerGuide, brokerGuideIndex, brokerGuideTopicFromUri, brokerGuideUri, buildJwtAuth, buildResourceMetadata, compileAuthorizationPolicy, compileProviderAllowedResources, diagnoseBroker, hasAuthorizationPolicies, isReservedBrokerSlot, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerConfig, loadBrokerGrammar, loadMcpbBundle, normalizeProviderAuthentication, providerMayPublish, providerPublishDecision, resolveOpenTarget, scopesOf, startBrokerServer, unzipMcpb, validateCapability } from './chunk-J5TN5RYU.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanmycelium/mcp-broker",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "WebSocket-based Model Context Protocol broker. Aggregates multiple MCP providers behind a single endpoint with stdio, SSE, and Streamable HTTP client transports.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -49,6 +49,7 @@
49
49
  }
50
50
  },
51
51
  "scripts": {
52
+ "leak-probe": "node --expose-gc scripts/leak-probe.mjs",
52
53
  "build": "tsup && npm run copy:assets",
53
54
  "clean": "rimraf dist",
54
55
  "copy:assets": "node scripts/copy-assets.mjs",
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Leak probe: the soak's round, in-process, with the garbage collector forced
3
+ * and the broker's own collections counted between batches.
4
+ *
5
+ * The soak reports RSS, which grows for reasons that are not leaks (V8 sizes
6
+ * its heap to the load and rarely gives pages back). This script asks the
7
+ * question the soak cannot: after a full GC, does the live heap keep growing
8
+ * with the number of rounds, and if so, which Map or Set inside the broker is
9
+ * growing with it.
10
+ *
11
+ * node --expose-gc scripts/leak-probe.mjs [rounds=2000] [provider-binary]
12
+ *
13
+ * The provider is the C sample (c/build/samples/host-provider), aggregate, so
14
+ * the three surfaces the soak drives are exercised: the slot, `_all`, and
15
+ * `_broker`. Prints one line per batch; a leak reads as a heapUsed column that
16
+ * climbs batch after batch, and as the collection that climbs with it.
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync } from "node:fs";
20
+ import * as net from "node:net";
21
+ import * as path from "node:path";
22
+ import * as url from "node:url";
23
+
24
+ import { WsTunnelBuilder } from "../dist/index.js";
25
+ import { connectMcp, toolText, waitForBroker } from "../../../../samples/lib/mcp-http-client.mjs";
26
+
27
+ if (typeof globalThis.gc !== "function") {
28
+ console.error("run with: node --expose-gc scripts/leak-probe.mjs");
29
+ process.exit(2);
30
+ }
31
+
32
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
33
+ const repo = path.resolve(here, "..", "..", "..", "..");
34
+ const ROUNDS = Number(process.argv[2] ?? 2000);
35
+ const BATCH = 250;
36
+ const SLOT = "probe";
37
+
38
+ function providerBinary() {
39
+ const given = process.argv[3];
40
+ const candidates = given
41
+ ? [given]
42
+ : ["host-provider", "host-provider.exe"].map((f) => path.join(repo, "c", "build", "samples", "host-provider", f));
43
+ const found = candidates.find((c) => existsSync(c));
44
+ if (!found) {
45
+ console.error(`host-provider not found (${candidates.join(", ")}); build c/ first`);
46
+ process.exit(2);
47
+ }
48
+ return found;
49
+ }
50
+
51
+ function freePort() {
52
+ return new Promise((resolve, reject) => {
53
+ const server = net.createServer();
54
+ server.listen(0, "127.0.0.1", () => {
55
+ const { port } = server.address();
56
+ server.close(() => resolve(port));
57
+ });
58
+ server.on("error", reject);
59
+ });
60
+ }
61
+
62
+ /** Every Map and Set reachable one level down from `obj`, with its size. */
63
+ function collections(obj, prefix) {
64
+ const out = [];
65
+ if (!obj || typeof obj !== "object") return out;
66
+ for (const key of Object.getOwnPropertyNames(obj)) {
67
+ const v = obj[key];
68
+ if (v instanceof Map || v instanceof Set) out.push([`${prefix}.${key}`, v.size]);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function snapshot(tunnel) {
74
+ const rows = [...collections(tunnel, "tunnel"), ...collections(tunnel._brokerServer, "_broker"), ...collections(tunnel._aggregateServer, "_all")];
75
+ for (const [name, state] of tunnel._providers) {
76
+ rows.push(...collections(state, `slot(${name})`));
77
+ if (state.httpEndpoint) rows.push([`slot(${name}).endpoint.sessions`, state.httpEndpoint.sessionCount ?? state.httpEndpoint._sessions?.size ?? -1]);
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ async function round(base, n) {
83
+ const text = `probe ${n}`;
84
+ const c = await connectMcp(base, SLOT);
85
+ await c.listTools();
86
+ const reply = toolText(await c.callTool("echo", { text }));
87
+ if (reply !== `${SLOT}: ${text}`) throw new Error(`echo mismatch: ${reply}`);
88
+ await c.close();
89
+
90
+ const a = await connectMcp(base, "_all");
91
+ const via = toolText(await a.callTool(`${SLOT}-echo`, { text }));
92
+ if (via !== `${SLOT}: ${text}`) throw new Error(`_all mismatch: ${via}`);
93
+ await a.close();
94
+
95
+ if (n % 50 === 0) {
96
+ const b = await connectMcp(base, "_broker");
97
+ await b.callTool("providers_list");
98
+ await b.callTool("broker_diagnose");
99
+ await b.close();
100
+ }
101
+ }
102
+
103
+ async function main() {
104
+ const port = await freePort();
105
+ const base = `http://127.0.0.1:${port}`;
106
+ const tunnel = new WsTunnelBuilder().withPort(port).withHost("127.0.0.1").withProviderHeartbeat(2000).build();
107
+ await tunnel.start();
108
+ await waitForBroker(base);
109
+
110
+ const provider = spawn(providerBinary(), ["--host", "127.0.0.1", "--port", String(port), "--name", SLOT, "--aggregate", "--retry-initial", "200"], {
111
+ stdio: ["ignore", "ignore", "inherit"],
112
+ });
113
+ await new Promise((r) => setTimeout(r, 800));
114
+
115
+ const mb = (b) => (b / 1048576).toFixed(1);
116
+ let previous = null;
117
+ const report = (label) => {
118
+ globalThis.gc();
119
+ globalThis.gc();
120
+ const m = process.memoryUsage();
121
+ const rows = snapshot(tunnel);
122
+ const grew = previous ? rows.filter(([k, v]) => v > (previous.get(k) ?? 0)) : [];
123
+ console.log(
124
+ `${label.padEnd(12)} heapUsed=${mb(m.heapUsed)}MB rss=${mb(m.rss)}MB external=${mb(m.external)}MB` +
125
+ (grew.length ? ` | growing: ${grew.map(([k, v]) => `${k}=${v}`).join(" ")}` : " | no collection grew")
126
+ );
127
+ previous = new Map(rows);
128
+ return m.heapUsed;
129
+ };
130
+
131
+ // Warm-up rounds first: JIT, caches, and the first sessions all allocate
132
+ // once; what matters is the slope after that.
133
+ for (let n = 1; n <= BATCH; n++) await round(base, n);
134
+ const baseline = report("warm-up");
135
+ const samples = [];
136
+ for (let n = BATCH + 1; n <= ROUNDS; n++) {
137
+ await round(base, n);
138
+ if (n % BATCH === 0) samples.push(report(`round ${n}`));
139
+ }
140
+
141
+ console.log("\ncollections after the last batch:");
142
+ for (const [k, v] of snapshot(tunnel)) if (v > 0) console.log(` ${k} = ${v}`);
143
+
144
+ const last = samples[samples.length - 1] ?? baseline;
145
+ const perRound = (last - baseline) / Math.max(1, ROUNDS - BATCH);
146
+ console.log(`\nlive heap after GC: ${mb(baseline)}MB after warm-up -> ${mb(last)}MB after ${ROUNDS} rounds (${perRound.toFixed(0)} bytes per round)`);
147
+
148
+ provider.kill();
149
+ await tunnel.stop();
150
+ process.exit(0);
151
+ }
152
+
153
+ main().catch((err) => {
154
+ console.error(err);
155
+ process.exit(1);
156
+ });
@@ -129,7 +129,29 @@ export interface IBrokerProviderInfo {
129
129
  /** `true` iff the slot is reachable for routing right now. */
130
130
  connected: boolean;
131
131
 
132
- /** Number of raw-WebSocket MCP clients on this slot. */
132
+ /**
133
+ * `true` when the provider is a member of the `_all` aggregate right now:
134
+ * it opted in and its `initialize` went through. The other side of
135
+ * {@link IBrokerAggregateInfo.providers}, per slot.
136
+ */
137
+ aggregate: boolean;
138
+
139
+ /**
140
+ * When the provider now serving the slot attached, ISO-8601; `null`
141
+ * while nothing serves it. Survives nothing: a reconnection is a new
142
+ * date, which is the point (a slot that says "since 3 s ago" every time
143
+ * you look is flapping).
144
+ */
145
+ connectedSince: string | null;
146
+
147
+ /** Milliseconds since `connectedSince`, computed at the time of the call; `null` when disconnected. */
148
+ connectedForMs: number | null;
149
+
150
+ /**
151
+ * Number of raw-WebSocket MCP clients on this slot. Clients, not the
152
+ * provider: a connected provider nobody is calling reads
153
+ * `clientCount: 0, sessionCount: 0, pendingCount: 0`.
154
+ */
133
155
  clientCount: number;
134
156
 
135
157
  /** Number of long-lived sessions (SSE + Streamable HTTP GET streams). */
@@ -4,7 +4,7 @@
4
4
  "description": "Get the broker's identity: name, version, uptime, host, port, TLS, and URL paths. Call this first when you need to know what you are talking to."
5
5
  },
6
6
  "providers_list": {
7
- "description": "List every provider slot on this broker, connected or not. Each entry tells you the transport kind, current client count, and how many requests are in flight. Use this to discover what you can route to."
7
+ "description": "List every provider slot on this broker, connected or not. Each entry tells you the transport kind, whether the provider is in `_all`, when it attached, the current client count, and how many requests are in flight. The counts are the slot's callers, not the provider: a connected provider nobody is calling reads all zeros. Use this to discover what you can route to."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Get the full status of one provider slot by name. Returns an error if the slot does not exist, start with providers_list if you are not sure of the name.",
@@ -4,7 +4,7 @@
4
4
  "description": "Récupère l'identité du broker : nom, version, uptime, hôte, port, TLS et chemins URL. À appeler en premier pour savoir à qui tu parles."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Liste tous les emplacements de providers de ce broker, connectés ou non. Chaque entrée indique le type de transport, le nombre de clients actuels et le nombre de requêtes en cours. Utilise-le pour découvrir vers qui tu peux router."
7
+ "description": "Liste tous les emplacements de providers de ce broker, connectés ou non. Chaque entrée indique le type de transport, l'appartenance à `_all`, la date de connexion du provider, le nombre de clients actuels et le nombre de requêtes en cours. Les compteurs sont ceux des appelants du slot, pas du provider : un provider connecté que personne n'appelle affiche des zéros. Utilise-le pour découvrir vers qui tu peux router."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Récupère le statut complet d'un emplacement de provider par son nom. Retourne une erreur si l'emplacement n'existe pas, commence par providers_list si tu n'es pas sûr du nom.",
@@ -4,7 +4,7 @@
4
4
  "description": "Returns the broker's name, version, uptime, host, port, TLS status, and configured URL paths."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Returns every provider slot known to the broker (connected and disconnected), with transport kind, client count, and pending request count."
7
+ "description": "Returns every provider slot known to the broker (connected and disconnected), with transport kind, `_all` membership, when the provider attached, client count, and pending request count. The counts are the slot's callers, not the provider: a connected provider nobody is calling reads all zeros."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Returns the status of one provider slot identified by name. Errors if the slot does not exist.",
@@ -4,7 +4,7 @@
4
4
  "description": "Retourne le nom, la version, l'uptime, l'hôte, le port, l'état TLS et les chemins URL du broker."
5
5
  },
6
6
  "providers_list": {
7
- "description": "Liste tous les emplacements de providers connus du broker (connectés et déconnectés), avec leur type de transport, leur nombre de clients et le nombre de requêtes en attente."
7
+ "description": "Liste tous les emplacements de providers connus du broker (connectés et déconnectés), avec leur type de transport, leur appartenance à `_all`, la date de connexion du provider, leur nombre de clients et le nombre de requêtes en attente. Les compteurs sont ceux des appelants du slot, pas du provider : un provider connecté que personne n'appelle affiche des zéros."
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "Retourne l'état d'un emplacement de provider identifié par son nom. Échoue si le provider n'existe pas.",
@@ -4,7 +4,7 @@
4
4
  "description": "返回 broker 的名称、版本、运行时间、主机、端口、TLS 状态和已配置的 URL 路径。"
5
5
  },
6
6
  "providers_list": {
7
- "description": "列出 broker 已知的所有 provider 槽位(已连接和未连接),包括传输类型、客户端数量和待处理请求数量。"
7
+ "description": "列出 broker 已知的所有 provider 槽位(已连接和未连接),包括传输类型、是否属于 `_all`、provider 的接入时间、客户端数量和待处理请求数量。计数针对槽位的调用方而非 provider:已连接但无人调用的 provider 显示全零。"
8
8
  },
9
9
  "provider_status": {
10
10
  "description": "返回由名称标识的某个 provider 槽位的状态。如果槽位不存在则返回错误。",
@@ -169,6 +169,12 @@ export interface IPendingRequest {
169
169
  export interface IProviderState {
170
170
  /** The active provider WebSocket, or `null` when the provider is not connected. */
171
171
  ws: WebSocket | null;
172
+ /**
173
+ * `Date.now()` when the provider currently serving the slot attached
174
+ * (socket, upstream or loopback), `null` while nothing serves it. What
175
+ * `providers_list` reports as `connectedSince`.
176
+ */
177
+ connectedSinceMs: number | null;
172
178
  /**
173
179
  * In-flight requests, keyed by the **broker-assigned** id that was written
174
180
  * into the frame sent to the provider. See {@link IPendingRequest.clientId}
@@ -482,10 +482,17 @@ export class WsTunnel implements IBrokerContext {
482
482
  connected = false;
483
483
  }
484
484
 
485
+ // `connected` is decided above from what is actually open; the
486
+ // timestamp only says since when, and is withheld when they disagree
487
+ // (an upstream that opened once and is now closed keeps no date).
488
+ const since = connected ? state.connectedSinceMs : null;
485
489
  return {
486
490
  name,
487
491
  transport,
488
492
  connected,
493
+ aggregate: this._aggregateServer?.providerNames.includes(name) ?? false,
494
+ connectedSince: since !== null ? new Date(since).toISOString() : null,
495
+ connectedForMs: since !== null ? Math.max(0, Date.now() - since) : null,
489
496
  clientCount: state.wsClients.size,
490
497
  sessionCount: state.sseSessions.size + state.httpSessions.size,
491
498
  pendingCount: state.pending.size,
@@ -513,6 +520,7 @@ export class WsTunnel implements IBrokerContext {
513
520
 
514
521
  const state = this._getOrCreateProviderState(name);
515
522
  this._loopbackProviders.set(name, transport);
523
+ state.connectedSinceMs = Date.now();
516
524
 
517
525
  transport.onMessage = (data: string) => this._routeFromProvider(state, name, data);
518
526
  transport.onClose = () => {
@@ -698,9 +706,10 @@ export class WsTunnel implements IBrokerContext {
698
706
  const state = this._providers.get(cfg.name);
699
707
  if (state) this._failProviderDisconnected(state, cfg.name);
700
708
  };
701
- if (cfg.aggregate) {
702
- upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
703
- }
709
+ upstream.onOpen = () => {
710
+ this._getOrCreateProviderState(cfg.name).connectedSinceMs = Date.now();
711
+ if (cfg.aggregate) void this._aggregateServer?.addProvider(cfg.name);
712
+ };
704
713
  this._upstreams.set(cfg.name, upstream);
705
714
  upstream.connect();
706
715
  };
@@ -1938,6 +1947,7 @@ export class WsTunnel implements IBrokerContext {
1938
1947
 
1939
1948
  const state = this._getOrCreateProviderState(name);
1940
1949
  state.ws = ws;
1950
+ state.connectedSinceMs = Date.now();
1941
1951
  this._watchProviderSocket(ws);
1942
1952
  if (providerPrincipal) {
1943
1953
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
@@ -2225,6 +2235,7 @@ export class WsTunnel implements IBrokerContext {
2225
2235
  providerNames.add(name);
2226
2236
  const state = this._getOrCreateProviderState(name);
2227
2237
  state.ws = ws;
2238
+ state.connectedSinceMs = Date.now();
2228
2239
  if (providerPrincipal) {
2229
2240
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
2230
2241
  }
@@ -2479,6 +2490,7 @@ export class WsTunnel implements IBrokerContext {
2479
2490
  * close handlers (dedicated WS, multiplexed WS, loopback).
2480
2491
  */
2481
2492
  private _failProviderDisconnected(state: IProviderState, name: string): void {
2493
+ state.connectedSinceMs = null;
2482
2494
  // Echoing the **client's** id rather than `null` or the broker's: a
2483
2495
  // Streamable HTTP session matches the answer to its held-open POST by
2484
2496
  // the id it chose, so an unaddressed error would leave that request
@@ -2516,6 +2528,7 @@ export class WsTunnel implements IBrokerContext {
2516
2528
  if (!state) {
2517
2529
  state = {
2518
2530
  ws: null,
2531
+ connectedSinceMs: null,
2519
2532
  pending: new Map(),
2520
2533
  sseSessions: new Map(),
2521
2534
  httpSessions: new Map(),