@cyanmycelium/mcp-broker 1.3.4 → 1.4.0
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/.mcp-broker.example/CONFIGURATION-EN.md +34 -0
- package/.mcp-broker.example/CONFIGURATION-FR.md +35 -0
- package/.mcp-broker.example/config.json +6 -0
- package/README.md +59 -1
- package/dist/bin.js +23 -1
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-J5TN5RYU.js → chunk-YTRVLPHP.js} +642 -9
- package/dist/chunk-YTRVLPHP.js.map +1 -0
- package/dist/index.d.ts +293 -2
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/authorization/capability.classifier.ts +14 -1
- package/src/bin.ts +26 -0
- package/src/broker/adapters/broker.adapter.providers.ts +18 -0
- package/src/broker/aggregate/aggregate.server.ts +8 -0
- package/src/broker/behaviors/broker.behavior.info.ts +10 -1
- package/src/broker/behaviors/broker.behavior.providers.ts +10 -1
- package/src/broker/broker.context.ts +23 -0
- package/src/broker/broker.guides.ts +56 -2
- package/src/config.ts +12 -0
- package/src/index.ts +9 -0
- package/src/subscriptions/resource.subscription.registry.ts +370 -0
- package/src/ws/ws.interfaces.ts +25 -1
- package/src/ws/ws.tunnel.builder.ts +13 -0
- package/src/ws/ws.tunnel.ts +324 -3
- package/dist/chunk-J5TN5RYU.js.map +0 -1
|
@@ -369,6 +369,28 @@ const GUIDE_PUBLISH_PROVIDER = [
|
|
|
369
369
|
"serving. A throttled background tab answers pings and still never replies to a",
|
|
370
370
|
"request; `providerRequestTimeoutMs` is what covers that case.",
|
|
371
371
|
"",
|
|
372
|
+
"### Resource updates",
|
|
373
|
+
"",
|
|
374
|
+
"The broker answers `resources/subscribe` and `resources/unsubscribe` itself and",
|
|
375
|
+
"sends your provider **one** subscribe per URI, however many clients asked, and",
|
|
376
|
+
"one unsubscribe when the last of them leaves. Implement them the plain MCP way;",
|
|
377
|
+
"an mcp-core `McpServer` (1.3.0 or later) does it for you, and a behavior only",
|
|
378
|
+
"raises `onResourceUpdated(uri)` (an `McpAdapterBase` calls",
|
|
379
|
+
"`_forwardResourceContentChanged(uri)`).",
|
|
380
|
+
"",
|
|
381
|
+
"- Every `notifications/resources/updated` must carry `params.uri`, the exact",
|
|
382
|
+
" string the client subscribed with. The broker delivers it to the sessions",
|
|
383
|
+
" subscribed to that URI and to no one else. One without a usable `uri` is",
|
|
384
|
+
" dropped and logged once per slot, never broadcast.",
|
|
385
|
+
"- After a reconnect the broker restores what clients still hold: it replays the",
|
|
386
|
+
" last `initialize` a client sent on the slot, then `notifications/initialized`,",
|
|
387
|
+
" then one `resources/subscribe` per URI. **Install your message handler before",
|
|
388
|
+
" the socket opens**, or that handshake waits for `providerRequestTimeoutMs`.",
|
|
389
|
+
" Each subscriber then gets one `notifications/resources/updated`, since the",
|
|
390
|
+
" content may have changed while you were away.",
|
|
391
|
+
"- A URI you refuse on replay is dropped along with its subscribers; their",
|
|
392
|
+
" re-read then fails, which is how they learn.",
|
|
393
|
+
"",
|
|
372
394
|
"## 8. Refusals you can receive",
|
|
373
395
|
"",
|
|
374
396
|
"On the slot-scoped socket a refusal is a WebSocket close with code 1008; the",
|
|
@@ -454,6 +476,13 @@ const GUIDE_CONNECT_CLIENT = [
|
|
|
454
476
|
"`broker://providers/{name}` and `broker://guide/{topic}`. It does not proxy any",
|
|
455
477
|
"other provider. It is available from the moment the broker finishes starting.",
|
|
456
478
|
"",
|
|
479
|
+
"`broker://providers` and `broker://providers/<name>` can be subscribed to: a",
|
|
480
|
+
"slot appearing, a provider attaching or detaching, a slot joining or leaving",
|
|
481
|
+
"`_all` each send `notifications/resources/updated`. Counters (`pendingCount`,",
|
|
482
|
+
"`clientCount`, `sessionCount`) never do, since reading moves them; read them",
|
|
483
|
+
"when you need them. A slot name is percent-encoded in its URI, so slot `a/b`",
|
|
484
|
+
"is `broker://providers/a%2Fb`.",
|
|
485
|
+
"",
|
|
457
486
|
"### `_all`",
|
|
458
487
|
"",
|
|
459
488
|
"An aggregate, not a proxy. It presents the union of the tools and prompts of",
|
|
@@ -487,6 +516,26 @@ const GUIDE_CONNECT_CLIENT = [
|
|
|
487
516
|
"changes its catalog. A client that subscribes sees a provider that arrives",
|
|
488
517
|
"mid-session without reconnecting.",
|
|
489
518
|
"",
|
|
519
|
+
"### Subscribing to resources",
|
|
520
|
+
"",
|
|
521
|
+
"On any slot but `_all`, `resources/subscribe` and `resources/unsubscribe` work on",
|
|
522
|
+
"every transport (WebSocket, SSE, Streamable HTTP, stdio). The broker keeps the",
|
|
523
|
+
"subscriptions per session and sends the provider one subscribe per URI, so N",
|
|
524
|
+
"clients cost the provider one. `notifications/resources/updated` reaches only",
|
|
525
|
+
"the sessions subscribed to that exact URI (no normalization); every other",
|
|
526
|
+
"notification is still broadcast to the whole slot.",
|
|
527
|
+
"",
|
|
528
|
+
"- Subscribing twice is one subscription; unsubscribing from something you do",
|
|
529
|
+
" not hold succeeds.",
|
|
530
|
+
"- Closing the socket, the SSE stream, or sending `DELETE /<slot>/mcp` releases",
|
|
531
|
+
" everything the session held. A Streamable HTTP session that is never deleted",
|
|
532
|
+
" never expires, and neither do its subscriptions.",
|
|
533
|
+
"- Limits: 64 URIs per client, 1024 client subscriptions per slot, URIs of at",
|
|
534
|
+
" most 2048 characters (`resourceSubscriptions` in the config). Past a limit the",
|
|
535
|
+
" answer is `-32000`, an overlong URI `-32602`.",
|
|
536
|
+
"- If the provider disconnects, subscriptions are kept and restored when a",
|
|
537
|
+
" provider comes back, followed by one `updated` per URI so you re-read.",
|
|
538
|
+
"",
|
|
490
539
|
"## 4. Browser clients and the origin check",
|
|
491
540
|
"",
|
|
492
541
|
"All three HTTP client endpoints, `/<slot>/mcp`, `/<slot>/sse` and",
|
|
@@ -758,7 +807,8 @@ const GUIDE_DEPLOY = [
|
|
|
758
807
|
' "stdioUpstreams": [',
|
|
759
808
|
' { "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], "aggregate": true }',
|
|
760
809
|
" ],",
|
|
761
|
-
' "mcpServers": [{ "name": "geo", "url": "https://geo.example.com/mcp" }]',
|
|
810
|
+
' "mcpServers": [{ "name": "geo", "url": "https://geo.example.com/mcp" }],',
|
|
811
|
+
' "resourceSubscriptions": { "maxSubscriptionsPerClient": 64, "maxSubscriptionsPerSlot": 1024, "maxResourceUriLength": 2048 }',
|
|
762
812
|
"}",
|
|
763
813
|
"```",
|
|
764
814
|
"",
|
|
@@ -832,6 +882,9 @@ const GUIDE_TROUBLESHOOTING = [
|
|
|
832
882
|
"| Browser provider gets a bare `error` event and never connects | provider auth is on; a browser cannot send the required header | run without a provider secret, or authenticate in a reverse proxy |",
|
|
833
883
|
"| `-32601 Method not found` on `_all` for `resources/list` | `_all` covers tools and prompts only | connect to the provider's own slot for resources |",
|
|
834
884
|
"| `-32602 Unknown aggregated tool` on a name you built yourself | prefixed names are capped, hashed and de-duplicated | re-run `tools/list` and pass the returned name back verbatim |",
|
|
885
|
+
"| Subscribed, but `notifications/resources/updated` never arrives | the provider's notification names a different URI (matching is exact), carries no `params.uri` (dropped and logged once), or your read grant was revoked (you were unsubscribed) | compare the URI byte for byte; check the broker log for a dropped update |",
|
|
886
|
+
"| `-32000 Subscription limit reached` | `maxSubscriptionsPerClient` or `maxSubscriptionsPerSlot` | unsubscribe what you no longer watch; raise the limit in `resourceSubscriptions` |",
|
|
887
|
+
"| `resourceSubscriptionCount` in `provider_status` only grows | Streamable HTTP clients leave without `DELETE`; their sessions and subscriptions never expire | send the DELETE on shutdown; restart the broker if the count is already large |",
|
|
835
888
|
"",
|
|
836
889
|
"## The transport/path mismatch, in detail",
|
|
837
890
|
"",
|
|
@@ -907,7 +960,8 @@ const GUIDE_TROUBLESHOOTING = [
|
|
|
907
960
|
"",
|
|
908
961
|
"Streamable HTTP and SSE sessions do not expire. A client that closes its tab",
|
|
909
962
|
"without sending `DELETE /<slot>/mcp` leaves its session alive, and every",
|
|
910
|
-
"notification the provider broadcasts is queued into it, without bound
|
|
963
|
+
"notification the provider broadcasts is queued into it, without bound, and",
|
|
964
|
+
"every resource subscription it took stays held, pinning the provider's own.",
|
|
911
965
|
"",
|
|
912
966
|
"The broker cannot tell a live session from an abandoned one, so no single",
|
|
913
967
|
"reading proves a leak. `sessionCount` in `provider_status` growing monotonically",
|
package/src/config.ts
CHANGED
|
@@ -102,6 +102,18 @@ export interface IBrokerConfig {
|
|
|
102
102
|
*/
|
|
103
103
|
providerRequestTimeoutMs?: number;
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Bounds on `resources/subscribe` bookkeeping. Each field maps to an env
|
|
107
|
+
* var: `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT` (default 64),
|
|
108
|
+
* `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT` (default 1024),
|
|
109
|
+
* `MCP_BROKER_MAX_RESOURCE_URI_LENGTH` (default 2048).
|
|
110
|
+
*/
|
|
111
|
+
resourceSubscriptions?: {
|
|
112
|
+
maxSubscriptionsPerClient?: number;
|
|
113
|
+
maxSubscriptionsPerSlot?: number;
|
|
114
|
+
maxResourceUriLength?: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
105
117
|
/**
|
|
106
118
|
* What happens when a provider connects to a slot another socket already
|
|
107
119
|
* holds. Maps to `MCP_BROKER_PROVIDER_TAKEOVER`.
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,15 @@ export type { IStdioUpstreamConfig, StdioUpstreamConfig } from "./stdio.upstream
|
|
|
6
6
|
export { RemoteUpstream } from "./remote.upstream";
|
|
7
7
|
export type { IRemoteUpstreamConfig, RemoteUpstreamConfig } from "./remote.upstream";
|
|
8
8
|
export type { IUpstream, Upstream } from "./upstream";
|
|
9
|
+
export { ResourceSubscriptionRegistry, DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS, SUBSCRIPTION_LIMIT_ERROR_CODE } from "./subscriptions/resource.subscription.registry";
|
|
10
|
+
export type {
|
|
11
|
+
ClientKey,
|
|
12
|
+
IReplayResult,
|
|
13
|
+
IResourceSubscriptionLimits,
|
|
14
|
+
IResourceSubscriptionUpstream,
|
|
15
|
+
SubscriptionOutcome,
|
|
16
|
+
SubscriptionState,
|
|
17
|
+
} from "./subscriptions/resource.subscription.registry";
|
|
9
18
|
|
|
10
19
|
// `.mcpb` bundle loading, verifies + unpacks a bundle into a stdio upstream.
|
|
11
20
|
export { loadMcpbBundle } from "./mcpb/mcpb.loader";
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-slot bookkeeping for `resources/subscribe`.
|
|
3
|
+
*
|
|
4
|
+
* A slot is one MCP server shared by every client attached to it, so the
|
|
5
|
+
* provider behind it must not see one subscription per browser tab: it sees
|
|
6
|
+
* one per URI. This registry owns that reference count. The first subscriber
|
|
7
|
+
* of a URI triggers the upstream `resources/subscribe`, the last one to leave
|
|
8
|
+
* triggers the upstream `resources/unsubscribe`, and everybody in between is
|
|
9
|
+
* answered locally.
|
|
10
|
+
*
|
|
11
|
+
* Every operation on one `(slot, uri)` pair runs through a queue, so two
|
|
12
|
+
* clients subscribing at the same instant cannot both send upstream, and an
|
|
13
|
+
* unsubscribe cannot overtake the subscribe it undoes. A second subscriber that
|
|
14
|
+
* arrives while the first upstream round trip is in flight waits for it: on
|
|
15
|
+
* success it is added locally, with no second upstream call; on failure it
|
|
16
|
+
* asks the provider itself, since the first refusal may have been transient.
|
|
17
|
+
*
|
|
18
|
+
* The registry does no I/O. The tunnel supplies the upstream calls through
|
|
19
|
+
* {@link IResourceSubscriptionUpstream} and decides what a subscriber is: `S`
|
|
20
|
+
* is whatever it needs to deliver a notification later.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Identifies one consumer across transports, e.g. `ws:7`, `http:<session>`, `stdio`. */
|
|
24
|
+
export type ClientKey = string;
|
|
25
|
+
|
|
26
|
+
/** An upstream answer, reduced to what the registry needs. */
|
|
27
|
+
export type SubscriptionOutcome = { readonly ok: true } | { readonly ok: false; readonly error: { readonly code: number; readonly message: string } };
|
|
28
|
+
|
|
29
|
+
/** What the tunnel provides so the registry can talk to a provider. */
|
|
30
|
+
export interface IResourceSubscriptionUpstream {
|
|
31
|
+
/** Sends one request to the provider behind `slot` and resolves with its answer, never rejects. */
|
|
32
|
+
request(slot: string, method: "resources/subscribe" | "resources/unsubscribe", uri: string): Promise<SubscriptionOutcome>;
|
|
33
|
+
/** `true` when a provider currently serves `slot`. */
|
|
34
|
+
isConnected(slot: string): boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bounds on what clients can make the broker hold. Without them one client, or
|
|
39
|
+
* many sessions a client never closes, could grow the registry without limit
|
|
40
|
+
* and pin upstream subscriptions forever.
|
|
41
|
+
*/
|
|
42
|
+
export interface IResourceSubscriptionLimits {
|
|
43
|
+
/** Distinct URIs one client may be subscribed to at once. */
|
|
44
|
+
readonly maxSubscriptionsPerClient: number;
|
|
45
|
+
/** Client/URI pairs one slot may hold at once. */
|
|
46
|
+
readonly maxSubscriptionsPerSlot: number;
|
|
47
|
+
/** Longest URI accepted, in UTF-16 code units. */
|
|
48
|
+
readonly maxResourceUriLength: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS: IResourceSubscriptionLimits = Object.freeze({
|
|
52
|
+
maxSubscriptionsPerClient: 64,
|
|
53
|
+
maxSubscriptionsPerSlot: 1024,
|
|
54
|
+
maxResourceUriLength: 2048,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where a URI stands with the provider.
|
|
59
|
+
*
|
|
60
|
+
* - `inactive`: not subscribed upstream. Either nothing was ever sent, or the
|
|
61
|
+
* provider disconnected and the subscription waits for {@link ResourceSubscriptionRegistry.replay}.
|
|
62
|
+
* - `subscribing` / `unsubscribing`: an upstream round trip is in flight.
|
|
63
|
+
* - `active`: the provider confirmed the subscription.
|
|
64
|
+
*/
|
|
65
|
+
export type SubscriptionState = "inactive" | "subscribing" | "active" | "unsubscribing";
|
|
66
|
+
|
|
67
|
+
/** Error code for a subscription refused by a broker limit. JSON-RPC reserves -32000..-32099 for servers. */
|
|
68
|
+
export const SUBSCRIPTION_LIMIT_ERROR_CODE = -32000;
|
|
69
|
+
|
|
70
|
+
interface IUriEntry<S> {
|
|
71
|
+
state: SubscriptionState;
|
|
72
|
+
/** Confirmed subscribers: they receive `notifications/resources/updated`. */
|
|
73
|
+
readonly subscribers: Map<ClientKey, S>;
|
|
74
|
+
/** Tail of this URI's operation queue. */
|
|
75
|
+
tail: Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One URI whose upstream subscription was re-sent after a provider reconnected. */
|
|
79
|
+
export interface IReplayResult<S> {
|
|
80
|
+
readonly uri: string;
|
|
81
|
+
readonly outcome: SubscriptionOutcome;
|
|
82
|
+
/** Who was subscribed at the time; they are dropped when the outcome is a failure. */
|
|
83
|
+
readonly subscribers: ReadonlyArray<{ readonly client: ClientKey; readonly sink: S }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const OK: SubscriptionOutcome = Object.freeze({ ok: true });
|
|
87
|
+
|
|
88
|
+
export class ResourceSubscriptionRegistry<S> {
|
|
89
|
+
private readonly _upstream: IResourceSubscriptionUpstream;
|
|
90
|
+
private readonly _limits: IResourceSubscriptionLimits;
|
|
91
|
+
|
|
92
|
+
/** slot → uri → entry. */
|
|
93
|
+
private readonly _slots = new Map<string, Map<string, IUriEntry<S>>>();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* client → slot → URIs, counting subscriptions still being confirmed.
|
|
97
|
+
* Counting those is what makes the per-client limit hold under concurrent
|
|
98
|
+
* requests: a reservation is taken before the upstream call, not after.
|
|
99
|
+
*/
|
|
100
|
+
private readonly _clients = new Map<ClientKey, Map<string, Set<string>>>();
|
|
101
|
+
|
|
102
|
+
/** slot → number of client/URI pairs, reserved ones included. */
|
|
103
|
+
private readonly _slotCounts = new Map<string, number>();
|
|
104
|
+
|
|
105
|
+
constructor(upstream: IResourceSubscriptionUpstream, limits: Partial<IResourceSubscriptionLimits> = {}) {
|
|
106
|
+
this._upstream = upstream;
|
|
107
|
+
this._limits = { ...DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS, ...limits };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get limits(): IResourceSubscriptionLimits {
|
|
111
|
+
return this._limits;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Subscribes `client` to `uri` on `slot`. Idempotent: subscribing twice
|
|
116
|
+
* keeps one entry and answers success.
|
|
117
|
+
*/
|
|
118
|
+
subscribe(slot: string, client: ClientKey, sink: S, uri: string): Promise<SubscriptionOutcome> {
|
|
119
|
+
if (uri.length === 0) return Promise.resolve(failure(-32602, "Missing required parameter: uri"));
|
|
120
|
+
if (uri.length > this._limits.maxResourceUriLength) {
|
|
121
|
+
return Promise.resolve(
|
|
122
|
+
failure(-32602, `Resource URI is ${uri.length} characters long; this broker accepts at most ${this._limits.maxResourceUriLength} (maxResourceUriLength).`)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const alreadyHeld = this._clients.get(client)?.get(slot)?.has(uri) ?? false;
|
|
127
|
+
if (!alreadyHeld) {
|
|
128
|
+
if (this._clientTotal(client) >= this._limits.maxSubscriptionsPerClient) {
|
|
129
|
+
return Promise.resolve(
|
|
130
|
+
failure(
|
|
131
|
+
SUBSCRIPTION_LIMIT_ERROR_CODE,
|
|
132
|
+
`Subscription limit reached: this client already holds ${this._limits.maxSubscriptionsPerClient} resource subscriptions (maxSubscriptionsPerClient). Unsubscribe from one first.`
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if ((this._slotCounts.get(slot) ?? 0) >= this._limits.maxSubscriptionsPerSlot) {
|
|
137
|
+
return Promise.resolve(
|
|
138
|
+
failure(
|
|
139
|
+
SUBSCRIPTION_LIMIT_ERROR_CODE,
|
|
140
|
+
`Subscription limit reached on slot "${slot}": it already holds ${this._limits.maxSubscriptionsPerSlot} client subscriptions (maxSubscriptionsPerSlot).`
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
this._reserve(slot, client, uri);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entry = this._entry(slot, uri, true)!;
|
|
148
|
+
return this._enqueue(entry, async () => {
|
|
149
|
+
// The client may have left while this waited in the queue.
|
|
150
|
+
if (!this._isReserved(slot, client, uri)) return OK;
|
|
151
|
+
|
|
152
|
+
if (entry.state === "active") {
|
|
153
|
+
entry.subscribers.set(client, sink);
|
|
154
|
+
return OK;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
entry.state = "subscribing";
|
|
158
|
+
const outcome = await this._upstream.request(slot, "resources/subscribe", uri);
|
|
159
|
+
if (outcome.ok) {
|
|
160
|
+
entry.state = "active";
|
|
161
|
+
// Re-checked after the await, for the same reason as above.
|
|
162
|
+
if (this._isReserved(slot, client, uri)) entry.subscribers.set(client, sink);
|
|
163
|
+
return OK;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
entry.state = "inactive";
|
|
167
|
+
this._release(slot, client, uri);
|
|
168
|
+
this._dropIfUnused(slot, uri, entry);
|
|
169
|
+
return outcome;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Unsubscribes `client` from `uri`. Always succeeds, including for a URI
|
|
175
|
+
* the client never subscribed to: the client asked not to be subscribed,
|
|
176
|
+
* and it is not. The provider is told only when the last subscriber leaves,
|
|
177
|
+
* and only when it is connected; its answer does not change ours.
|
|
178
|
+
*/
|
|
179
|
+
unsubscribe(slot: string, client: ClientKey, uri: string): Promise<SubscriptionOutcome> {
|
|
180
|
+
if (!this._isReserved(slot, client, uri)) return Promise.resolve(OK);
|
|
181
|
+
this._release(slot, client, uri);
|
|
182
|
+
|
|
183
|
+
const entry = this._entry(slot, uri, false);
|
|
184
|
+
if (!entry) return Promise.resolve(OK);
|
|
185
|
+
|
|
186
|
+
return this._enqueue(entry, async () => {
|
|
187
|
+
entry.subscribers.delete(client);
|
|
188
|
+
if (entry.subscribers.size > 0 || this._hasReservation(slot, uri)) return OK;
|
|
189
|
+
|
|
190
|
+
if (entry.state === "active" && this._upstream.isConnected(slot)) {
|
|
191
|
+
entry.state = "unsubscribing";
|
|
192
|
+
await this._upstream.request(slot, "resources/unsubscribe", uri);
|
|
193
|
+
}
|
|
194
|
+
entry.state = "inactive";
|
|
195
|
+
this._dropIfUnused(slot, uri, entry);
|
|
196
|
+
return OK;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Confirmed subscribers of `uri` on `slot`, the only ones a notification may reach. */
|
|
201
|
+
subscribers(slot: string, uri: string): ReadonlyArray<{ readonly client: ClientKey; readonly sink: S }> {
|
|
202
|
+
const entry = this._slots.get(slot)?.get(uri);
|
|
203
|
+
if (!entry) return [];
|
|
204
|
+
return [...entry.subscribers].map(([client, sink]) => ({ client, sink }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Where `uri` stands with the provider behind `slot`. */
|
|
208
|
+
stateOf(slot: string, uri: string): SubscriptionState {
|
|
209
|
+
return this._slots.get(slot)?.get(uri)?.state ?? "inactive";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Number of client/URI pairs on `slot`, subscriptions being confirmed included. */
|
|
213
|
+
countFor(slot: string): number {
|
|
214
|
+
return this._slotCounts.get(slot) ?? 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `true` when `slot` has at least one URI someone is subscribed to. */
|
|
218
|
+
hasSubscriptions(slot: string): boolean {
|
|
219
|
+
for (const entry of this._slots.get(slot)?.values() ?? []) {
|
|
220
|
+
if (entry.subscribers.size > 0) return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Drops every subscription `client` holds, on every slot. Safe to call
|
|
227
|
+
* repeatedly and for a client that holds nothing.
|
|
228
|
+
*/
|
|
229
|
+
async removeClient(client: ClientKey): Promise<void> {
|
|
230
|
+
const perSlot = this._clients.get(client);
|
|
231
|
+
if (!perSlot) return;
|
|
232
|
+
const pending: Promise<SubscriptionOutcome>[] = [];
|
|
233
|
+
for (const [slot, uris] of [...perSlot]) {
|
|
234
|
+
for (const uri of [...uris]) pending.push(this.unsubscribe(slot, client, uri));
|
|
235
|
+
}
|
|
236
|
+
await Promise.all(pending);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Records that the provider behind `slot` went away: whatever it had
|
|
241
|
+
* subscribed is gone with it. Subscribers are kept, so {@link replay} can
|
|
242
|
+
* restore them when a provider comes back.
|
|
243
|
+
*/
|
|
244
|
+
providerDisconnected(slot: string): void {
|
|
245
|
+
for (const entry of this._slots.get(slot)?.values() ?? []) entry.state = "inactive";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Re-sends one upstream `resources/subscribe` per URI that still has
|
|
250
|
+
* subscribers, after a provider (re)attached to `slot`. A URI the new
|
|
251
|
+
* provider refuses is dropped along with its subscribers, since nothing
|
|
252
|
+
* will ever notify them; the result lists them so the caller can tell them.
|
|
253
|
+
*/
|
|
254
|
+
async replay(slot: string): Promise<IReplayResult<S>[]> {
|
|
255
|
+
const entries = [...(this._slots.get(slot) ?? [])].filter(([, entry]) => entry.subscribers.size > 0);
|
|
256
|
+
return Promise.all(
|
|
257
|
+
entries.map(([uri, entry]) =>
|
|
258
|
+
this._enqueue(entry, async (): Promise<IReplayResult<S>> => {
|
|
259
|
+
const subscribers = [...entry.subscribers].map(([client, sink]) => ({ client, sink }));
|
|
260
|
+
if (entry.state === "active" || entry.subscribers.size === 0) return { uri, outcome: OK, subscribers };
|
|
261
|
+
entry.state = "subscribing";
|
|
262
|
+
const outcome = await this._upstream.request(slot, "resources/subscribe", uri);
|
|
263
|
+
if (outcome.ok) {
|
|
264
|
+
entry.state = "active";
|
|
265
|
+
} else {
|
|
266
|
+
entry.state = "inactive";
|
|
267
|
+
for (const { client } of subscribers) {
|
|
268
|
+
entry.subscribers.delete(client);
|
|
269
|
+
this._release(slot, client, uri);
|
|
270
|
+
}
|
|
271
|
+
this._dropIfUnused(slot, uri, entry);
|
|
272
|
+
}
|
|
273
|
+
return { uri, outcome, subscribers };
|
|
274
|
+
})
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Forgets everything without calling upstream. Used when the broker stops. */
|
|
280
|
+
clear(): void {
|
|
281
|
+
this._slots.clear();
|
|
282
|
+
this._clients.clear();
|
|
283
|
+
this._slotCounts.clear();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// -------------------------------------------------------------------------
|
|
287
|
+
// Internals
|
|
288
|
+
// -------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
private _enqueue<T>(entry: IUriEntry<S>, op: () => Promise<T>): Promise<T> {
|
|
291
|
+
const run = entry.tail.then(op);
|
|
292
|
+
entry.tail = run.then(
|
|
293
|
+
() => undefined,
|
|
294
|
+
() => undefined
|
|
295
|
+
);
|
|
296
|
+
return run;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private _entry(slot: string, uri: string, create: boolean): IUriEntry<S> | undefined {
|
|
300
|
+
let uris = this._slots.get(slot);
|
|
301
|
+
if (!uris) {
|
|
302
|
+
if (!create) return undefined;
|
|
303
|
+
uris = new Map();
|
|
304
|
+
this._slots.set(slot, uris);
|
|
305
|
+
}
|
|
306
|
+
let entry = uris.get(uri);
|
|
307
|
+
if (!entry && create) {
|
|
308
|
+
entry = { state: "inactive", subscribers: new Map(), tail: Promise.resolve() };
|
|
309
|
+
uris.set(uri, entry);
|
|
310
|
+
}
|
|
311
|
+
return entry;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Removes an entry nobody holds or waits for, once its queue is idle. */
|
|
315
|
+
private _dropIfUnused(slot: string, uri: string, entry: IUriEntry<S>): void {
|
|
316
|
+
if (entry.subscribers.size > 0 || this._hasReservation(slot, uri) || entry.state !== "inactive") return;
|
|
317
|
+
const uris = this._slots.get(slot);
|
|
318
|
+
if (uris?.get(uri) !== entry) return;
|
|
319
|
+
uris.delete(uri);
|
|
320
|
+
if (uris.size === 0) this._slots.delete(slot);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private _reserve(slot: string, client: ClientKey, uri: string): void {
|
|
324
|
+
let perSlot = this._clients.get(client);
|
|
325
|
+
if (!perSlot) {
|
|
326
|
+
perSlot = new Map();
|
|
327
|
+
this._clients.set(client, perSlot);
|
|
328
|
+
}
|
|
329
|
+
let uris = perSlot.get(slot);
|
|
330
|
+
if (!uris) {
|
|
331
|
+
uris = new Set();
|
|
332
|
+
perSlot.set(slot, uris);
|
|
333
|
+
}
|
|
334
|
+
uris.add(uri);
|
|
335
|
+
this._slotCounts.set(slot, (this._slotCounts.get(slot) ?? 0) + 1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private _release(slot: string, client: ClientKey, uri: string): void {
|
|
339
|
+
const perSlot = this._clients.get(client);
|
|
340
|
+
const uris = perSlot?.get(slot);
|
|
341
|
+
if (!uris?.delete(uri)) return;
|
|
342
|
+
if (uris.size === 0) perSlot!.delete(slot);
|
|
343
|
+
if (perSlot!.size === 0) this._clients.delete(client);
|
|
344
|
+
const count = (this._slotCounts.get(slot) ?? 1) - 1;
|
|
345
|
+
if (count > 0) this._slotCounts.set(slot, count);
|
|
346
|
+
else this._slotCounts.delete(slot);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private _isReserved(slot: string, client: ClientKey, uri: string): boolean {
|
|
350
|
+
return this._clients.get(client)?.get(slot)?.has(uri) ?? false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** `true` when some client reserved `uri` on `slot`, confirmed or not. */
|
|
354
|
+
private _hasReservation(slot: string, uri: string): boolean {
|
|
355
|
+
for (const perSlot of this._clients.values()) {
|
|
356
|
+
if (perSlot.get(slot)?.has(uri)) return true;
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private _clientTotal(client: ClientKey): number {
|
|
362
|
+
let n = 0;
|
|
363
|
+
for (const uris of this._clients.get(client)?.values() ?? []) n += uris.size;
|
|
364
|
+
return n;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function failure(code: number, message: string): SubscriptionOutcome {
|
|
369
|
+
return { ok: false, error: { code, message } };
|
|
370
|
+
}
|
package/src/ws/ws.interfaces.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { IStdioUpstreamConfig } from "../stdio.upstream";
|
|
|
6
6
|
import type { IRemoteUpstreamConfig } from "../remote.upstream";
|
|
7
7
|
import type { IResolvedAuth, IProviderAuthenticator, IPrincipal } from "../auth/index";
|
|
8
8
|
import type { IPolicyAuthorization, ISlotResourceResolver } from "../authorization/index";
|
|
9
|
+
import type { IResourceSubscriptionLimits } from "../subscriptions/resource.subscription.registry";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Every type the WebSocket tunnel exchanges or is configured with.
|
|
@@ -36,7 +37,10 @@ export type ResponseSink =
|
|
|
36
37
|
| { type: "sse"; sessionId: string }
|
|
37
38
|
| { type: "http-session"; sessionId: string }
|
|
38
39
|
| { type: "stdio" }
|
|
39
|
-
| { type: "internal"; client: IInternalClient }
|
|
40
|
+
| { type: "internal"; client: IInternalClient }
|
|
41
|
+
// A request the broker itself sent (an aggregated `resources/subscribe`,
|
|
42
|
+
// the handshake before a replay). Its answer is consumed, never relayed.
|
|
43
|
+
| { type: "broker"; resolve: (frame: string) => void };
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
46
|
* The client-transport handlers a provider route can resolve to.
|
|
@@ -191,6 +195,13 @@ export interface IProviderState {
|
|
|
191
195
|
readonly internalClients: Set<IInternalClient>;
|
|
192
196
|
/** Lazily built Streamable HTTP endpoint serving this slot. */
|
|
193
197
|
httpEndpoint: StreamableHttpEndpoint | null;
|
|
198
|
+
/**
|
|
199
|
+
* `params` of the last `initialize` a client sent on this slot. Replayed
|
|
200
|
+
* to a provider that reconnects while subscriptions are held, so the
|
|
201
|
+
* broker can restore them on a session the provider considers open,
|
|
202
|
+
* instead of sending requests to one that was never initialized.
|
|
203
|
+
*/
|
|
204
|
+
lastInitializeParams?: unknown;
|
|
194
205
|
}
|
|
195
206
|
|
|
196
207
|
// ---------------------------------------------------------------------------
|
|
@@ -363,6 +374,19 @@ export interface IWsTunnelOptions {
|
|
|
363
374
|
*/
|
|
364
375
|
providerRequestTimeoutMs?: number;
|
|
365
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Bounds on `resources/subscribe` bookkeeping. Every field is optional and
|
|
379
|
+
* falls back to {@link DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS}: 64 URIs per
|
|
380
|
+
* client, 1024 client subscriptions per slot, URIs of at most 2048
|
|
381
|
+
* characters. A subscription past a limit is refused with `-32000`, an
|
|
382
|
+
* overlong URI with `-32602`.
|
|
383
|
+
*
|
|
384
|
+
* The per-slot bound is what caps a Streamable HTTP client that closes its
|
|
385
|
+
* tab without `DELETE`: its session never expires, so neither do its
|
|
386
|
+
* subscriptions.
|
|
387
|
+
*/
|
|
388
|
+
resourceSubscriptions?: Partial<IResourceSubscriptionLimits>;
|
|
389
|
+
|
|
366
390
|
/**
|
|
367
391
|
* Optional static-file mounts served over plain HTTP.
|
|
368
392
|
* Matched by longest URL prefix; directory requests fall back to `index.html`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { WsTunnel } from "./ws.tunnel";
|
|
3
3
|
import type { AllowedOrigins, IStaticMount, IWsTunnelOptions, ProviderTakeoverMode } from "./ws.interfaces";
|
|
4
|
+
import type { IResourceSubscriptionLimits } from "../subscriptions/resource.subscription.registry";
|
|
4
5
|
import type { IStdioUpstreamConfig } from "../stdio.upstream";
|
|
5
6
|
import type { IRemoteUpstreamConfig } from "../remote.upstream";
|
|
6
7
|
import { buildJwtAuth, SharedSecretProviderAuthenticator, type IJwtAuthOptions, type IResolvedAuth, type IProviderAuthenticator } from "../auth/index";
|
|
@@ -44,6 +45,7 @@ export class WsTunnelBuilder {
|
|
|
44
45
|
private _providerHeartbeatIntervalMs: number | undefined = undefined;
|
|
45
46
|
private _providerTakeover: ProviderTakeoverMode | undefined = undefined;
|
|
46
47
|
private _providerRequestTimeoutMs: number | undefined = undefined;
|
|
48
|
+
private _resourceSubscriptions: Partial<IResourceSubscriptionLimits> | undefined = undefined;
|
|
47
49
|
private _staticMounts: IStaticMount[] = [];
|
|
48
50
|
private _stdioUpstreams: IStdioUpstreamConfig[] = [];
|
|
49
51
|
private _remoteUpstreams: IRemoteUpstreamConfig[] = [];
|
|
@@ -212,6 +214,16 @@ export class WsTunnelBuilder {
|
|
|
212
214
|
return this;
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Bounds what `resources/subscribe` can make the broker hold. Fields left
|
|
219
|
+
* out keep their default: 64 URIs per client, 1024 client subscriptions per
|
|
220
|
+
* slot, URIs of at most 2048 characters.
|
|
221
|
+
*/
|
|
222
|
+
withResourceSubscriptionLimits(limits: Partial<IResourceSubscriptionLimits>): this {
|
|
223
|
+
this._resourceSubscriptions = { ...this._resourceSubscriptions, ...limits };
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
215
227
|
/**
|
|
216
228
|
* Adds a static-file mount served over plain HTTP.
|
|
217
229
|
* Can be called multiple times; longest-prefix match wins at runtime.
|
|
@@ -373,6 +385,7 @@ export class WsTunnelBuilder {
|
|
|
373
385
|
providerHeartbeatIntervalMs: this._providerHeartbeatIntervalMs,
|
|
374
386
|
providerTakeover: this._providerTakeover,
|
|
375
387
|
providerRequestTimeoutMs: this._providerRequestTimeoutMs,
|
|
388
|
+
resourceSubscriptions: this._resourceSubscriptions,
|
|
376
389
|
staticMounts: this._staticMounts.length > 0 ? [...this._staticMounts] : undefined,
|
|
377
390
|
stdioUpstreams: this._stdioUpstreams.length > 0 ? [...this._stdioUpstreams] : undefined,
|
|
378
391
|
remoteUpstreams: this._remoteUpstreams.length > 0 ? [...this._remoteUpstreams] : undefined,
|