@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.
- package/README.md +34 -0
- package/dist/bin.js +1 -1
- package/dist/{chunk-BZUZYXVA.js → chunk-J5TN5RYU.js} +40 -5
- package/dist/chunk-J5TN5RYU.js.map +1 -0
- package/dist/grammars/claude/en.json +1 -1
- package/dist/grammars/claude/fr.json +1 -1
- package/dist/grammars/default/en.json +1 -1
- package/dist/grammars/default/fr.json +1 -1
- package/dist/grammars/default/zh.json +1 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +1 -1
- package/package.json +2 -1
- package/scripts/leak-probe.mjs +156 -0
- package/src/broker/aggregate/aggregate.server.ts +10 -0
- package/src/broker/broker.context.ts +23 -1
- package/src/broker/grammars/claude/en.json +1 -1
- package/src/broker/grammars/claude/fr.json +1 -1
- package/src/broker/grammars/default/en.json +1 -1
- package/src/broker/grammars/default/fr.json +1 -1
- package/src/broker/grammars/default/zh.json +1 -1
- package/src/ws/ws.interfaces.ts +6 -0
- package/src/ws/ws.tunnel.ts +34 -4
- package/dist/chunk-BZUZYXVA.js.map +0 -1
|
@@ -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
|
-
/**
|
|
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-
|
|
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.
|
|
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
|
-
/**
|
|
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 槽位的状态。如果槽位不存在则返回错误。",
|
package/src/ws/ws.interfaces.ts
CHANGED
|
@@ -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}
|
package/src/ws/ws.tunnel.ts
CHANGED
|
@@ -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
|
-
|
|
685
|
-
|
|
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(),
|