@cyanmycelium/mcp-broker 1.3.0 → 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;
@@ -1441,6 +1460,17 @@ declare class WsTunnel implements IBrokerContext {
1441
1460
  getSecurityInfo(): IBrokerSecurityInfo;
1442
1461
  /** Slot the stdio bridge is pinned to, or `null` when there is no bridge. */
1443
1462
  getStdioBridgeTarget(): string | null;
1463
+ /**
1464
+ * Membership of the `_all` aggregate, for `broker_diagnose`.
1465
+ *
1466
+ * Before `start()` the aggregate server does not exist yet, so an enabled
1467
+ * aggregate reports no provider rather than `undefined`: "cannot be read"
1468
+ * is for a host that has no aggregate at all, not for one that has not
1469
+ * started. Without this accessor the CLI's own broker skipped the
1470
+ * `aggregate-empty` check and told the operator to call tools/list on
1471
+ * `_all` by hand.
1472
+ */
1473
+ getAggregateInfo(): IBrokerAggregateInfo;
1444
1474
  getProvidersInfo(): IBrokerProviderInfo[];
1445
1475
  getProviderInfo(name: string): IBrokerProviderInfo | undefined;
1446
1476
  private _buildProviderInfo;
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-BZUZYXVA.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.0",
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
+ });
@@ -163,6 +163,16 @@ export class AggregateServer implements IMessageTransport {
163
163
  }
164
164
  }
165
165
 
166
+ /**
167
+ * Slot names currently contributing to the aggregate, in registration
168
+ * order. A provider whose handshake failed is not in it: `addProvider`
169
+ * removed it. This is what `broker_diagnose` reads through
170
+ * `IBrokerContext.getAggregateInfo()`.
171
+ */
172
+ get providerNames(): readonly string[] {
173
+ return [...this._sessions.keys()];
174
+ }
175
+
166
176
  /** Removes a provider from the aggregate. */
167
177
  removeProvider(name: string): void {
168
178
  const session = this._sessions.get(name);
@@ -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}
@@ -19,7 +19,7 @@ import type { IBrokerContext, IBrokerProviderInfo, BrokerProviderTransport } fro
19
19
  // Imported from the defining module rather than the barrel: these two are the
20
20
  // optional `IBrokerContext` extensions `broker_diagnose` reads, and the barrel
21
21
  // does not re-export them yet.
22
- import type { IBrokerSecurityInfo } from "../broker/broker.context";
22
+ import type { IBrokerAggregateInfo, IBrokerSecurityInfo } from "../broker/broker.context";
23
23
  import { AggregateServer } from "../broker/aggregate/aggregate.server";
24
24
  import {
25
25
  HttpAuthGuard,
@@ -433,6 +433,23 @@ export class WsTunnel implements IBrokerContext {
433
433
  return this._options.stdioClient?.providerName ?? null;
434
434
  }
435
435
 
436
+ /**
437
+ * Membership of the `_all` aggregate, for `broker_diagnose`.
438
+ *
439
+ * Before `start()` the aggregate server does not exist yet, so an enabled
440
+ * aggregate reports no provider rather than `undefined`: "cannot be read"
441
+ * is for a host that has no aggregate at all, not for one that has not
442
+ * started. Without this accessor the CLI's own broker skipped the
443
+ * `aggregate-empty` check and told the operator to call tools/list on
444
+ * `_all` by hand.
445
+ */
446
+ public getAggregateInfo(): IBrokerAggregateInfo {
447
+ return {
448
+ enabled: this._options.enableAggregateProvider !== false,
449
+ providers: this._aggregateServer?.providerNames ?? [],
450
+ };
451
+ }
452
+
436
453
  public getProvidersInfo(): IBrokerProviderInfo[] {
437
454
  const out: IBrokerProviderInfo[] = [];
438
455
  for (const [name, state] of this._providers) {
@@ -465,10 +482,17 @@ export class WsTunnel implements IBrokerContext {
465
482
  connected = false;
466
483
  }
467
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;
468
489
  return {
469
490
  name,
470
491
  transport,
471
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,
472
496
  clientCount: state.wsClients.size,
473
497
  sessionCount: state.sseSessions.size + state.httpSessions.size,
474
498
  pendingCount: state.pending.size,
@@ -496,6 +520,7 @@ export class WsTunnel implements IBrokerContext {
496
520
 
497
521
  const state = this._getOrCreateProviderState(name);
498
522
  this._loopbackProviders.set(name, transport);
523
+ state.connectedSinceMs = Date.now();
499
524
 
500
525
  transport.onMessage = (data: string) => this._routeFromProvider(state, name, data);
501
526
  transport.onClose = () => {
@@ -681,9 +706,10 @@ export class WsTunnel implements IBrokerContext {
681
706
  const state = this._providers.get(cfg.name);
682
707
  if (state) this._failProviderDisconnected(state, cfg.name);
683
708
  };
684
- if (cfg.aggregate) {
685
- upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
686
- }
709
+ upstream.onOpen = () => {
710
+ this._getOrCreateProviderState(cfg.name).connectedSinceMs = Date.now();
711
+ if (cfg.aggregate) void this._aggregateServer?.addProvider(cfg.name);
712
+ };
687
713
  this._upstreams.set(cfg.name, upstream);
688
714
  upstream.connect();
689
715
  };
@@ -1921,6 +1947,7 @@ export class WsTunnel implements IBrokerContext {
1921
1947
 
1922
1948
  const state = this._getOrCreateProviderState(name);
1923
1949
  state.ws = ws;
1950
+ state.connectedSinceMs = Date.now();
1924
1951
  this._watchProviderSocket(ws);
1925
1952
  if (providerPrincipal) {
1926
1953
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
@@ -2208,6 +2235,7 @@ export class WsTunnel implements IBrokerContext {
2208
2235
  providerNames.add(name);
2209
2236
  const state = this._getOrCreateProviderState(name);
2210
2237
  state.ws = ws;
2238
+ state.connectedSinceMs = Date.now();
2211
2239
  if (providerPrincipal) {
2212
2240
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
2213
2241
  }
@@ -2462,6 +2490,7 @@ export class WsTunnel implements IBrokerContext {
2462
2490
  * close handlers (dedicated WS, multiplexed WS, loopback).
2463
2491
  */
2464
2492
  private _failProviderDisconnected(state: IProviderState, name: string): void {
2493
+ state.connectedSinceMs = null;
2465
2494
  // Echoing the **client's** id rather than `null` or the broker's: a
2466
2495
  // Streamable HTTP session matches the answer to its held-open POST by
2467
2496
  // the id it chose, so an unaddressed error would leave that request
@@ -2499,6 +2528,7 @@ export class WsTunnel implements IBrokerContext {
2499
2528
  if (!state) {
2500
2529
  state = {
2501
2530
  ws: null,
2531
+ connectedSinceMs: null,
2502
2532
  pending: new Map(),
2503
2533
  sseSessions: new Map(),
2504
2534
  httpSessions: new Map(),