@cyanmycelium/mcp-broker 1.3.3 → 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.
@@ -6,9 +6,9 @@ import * as nodePath from 'path';
6
6
  import { join, dirname, resolve, relative, isAbsolute, sep } from 'path';
7
7
  import { randomUUID, timingSafeEqual, X509Certificate, createPublicKey, verify } from 'crypto';
8
8
  import { WebSocket, WebSocketServer } from 'ws';
9
+ import { McpAuthError, McpGrammar, McpBehavior, McpAdapterBase, McpToolResults, LoopbackTransport, McpServerBuilder, scopesOf as scopesOf$1, buildProtectedResourceMetadata, PROTECTED_RESOURCE_METADATA_PREFIX, bearerToken, buildChallengeHeader, createEventEmitter } from '@cyanmycelium/mcp-core';
9
10
  import { StdioTransport, StreamableHttpEndpoint } from '@cyanmycelium/mcp-core/node';
10
11
  import { spawn } from 'child_process';
11
- import { McpAuthError, McpGrammar, McpBehavior, McpAdapterBase, McpToolResults, LoopbackTransport, McpServerBuilder, scopesOf as scopesOf$1, buildProtectedResourceMetadata, PROTECTED_RESOURCE_METADATA_PREFIX, bearerToken, buildChallengeHeader } from '@cyanmycelium/mcp-core';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { createRemoteJWKSet, jwtVerify, errors } from 'jose';
14
14
  import { createRequire } from 'module';
@@ -639,6 +639,14 @@ var BrokerInfoBehavior = class _BrokerInfoBehavior extends McpBehavior {
639
639
  namespace: _BrokerInfoBehavior.NAMESPACE
640
640
  });
641
641
  }
642
+ /**
643
+ * Always reads live. `McpBehavior` caches the content of its root
644
+ * resource on first read and never refreshes it, which suits static
645
+ * content but froze this snapshot at whatever the first reader saw.
646
+ */
647
+ readResourceAsync(uri) {
648
+ return this.adapter.readResourceAsync(uri);
649
+ }
642
650
  _buildResources() {
643
651
  return [
644
652
  {
@@ -665,10 +673,18 @@ var BrokerInfoBehavior = class _BrokerInfoBehavior extends McpBehavior {
665
673
  };
666
674
  var PROVIDERS_URI = "broker://providers";
667
675
  var PROVIDER_URI_TEMPLATE = "broker://providers/{name}";
676
+ function providerUri(name) {
677
+ return `broker://providers/${encodeURIComponent(name)}`;
678
+ }
668
679
  var BrokerProvidersAdapter = class extends McpAdapterBase {
669
680
  constructor(_context) {
670
681
  super("broker");
671
682
  this._context = _context;
683
+ _context.onProvidersChanged?.subscribe((names) => {
684
+ if (names.length === 0) return;
685
+ this._forwardResourceContentChanged(PROVIDERS_URI);
686
+ for (const name of new Set(names)) this._forwardResourceContentChanged(providerUri(name));
687
+ });
672
688
  }
673
689
  _context;
674
690
  async readResourceAsync(uri) {
@@ -721,6 +737,14 @@ var BrokerProvidersBehavior = class _BrokerProvidersBehavior extends McpBehavior
721
737
  namespace: _BrokerProvidersBehavior.NAMESPACE
722
738
  });
723
739
  }
740
+ /**
741
+ * Always reads live. `McpBehavior` caches the content of its root
742
+ * resource on first read and never refreshes it, which suits static
743
+ * content but froze this snapshot at whatever the first reader saw.
744
+ */
745
+ readResourceAsync(uri) {
746
+ return this.adapter.readResourceAsync(uri);
747
+ }
724
748
  _buildResources() {
725
749
  return [
726
750
  {
@@ -1070,6 +1094,28 @@ var GUIDE_PUBLISH_PROVIDER = [
1070
1094
  "serving. A throttled background tab answers pings and still never replies to a",
1071
1095
  "request; `providerRequestTimeoutMs` is what covers that case.",
1072
1096
  "",
1097
+ "### Resource updates",
1098
+ "",
1099
+ "The broker answers `resources/subscribe` and `resources/unsubscribe` itself and",
1100
+ "sends your provider **one** subscribe per URI, however many clients asked, and",
1101
+ "one unsubscribe when the last of them leaves. Implement them the plain MCP way;",
1102
+ "an mcp-core `McpServer` (1.3.0 or later) does it for you, and a behavior only",
1103
+ "raises `onResourceUpdated(uri)` (an `McpAdapterBase` calls",
1104
+ "`_forwardResourceContentChanged(uri)`).",
1105
+ "",
1106
+ "- Every `notifications/resources/updated` must carry `params.uri`, the exact",
1107
+ " string the client subscribed with. The broker delivers it to the sessions",
1108
+ " subscribed to that URI and to no one else. One without a usable `uri` is",
1109
+ " dropped and logged once per slot, never broadcast.",
1110
+ "- After a reconnect the broker restores what clients still hold: it replays the",
1111
+ " last `initialize` a client sent on the slot, then `notifications/initialized`,",
1112
+ " then one `resources/subscribe` per URI. **Install your message handler before",
1113
+ " the socket opens**, or that handshake waits for `providerRequestTimeoutMs`.",
1114
+ " Each subscriber then gets one `notifications/resources/updated`, since the",
1115
+ " content may have changed while you were away.",
1116
+ "- A URI you refuse on replay is dropped along with its subscribers; their",
1117
+ " re-read then fails, which is how they learn.",
1118
+ "",
1073
1119
  "## 8. Refusals you can receive",
1074
1120
  "",
1075
1121
  "On the slot-scoped socket a refusal is a WebSocket close with code 1008; the",
@@ -1150,6 +1196,13 @@ var GUIDE_CONNECT_CLIENT = [
1150
1196
  "`broker://providers/{name}` and `broker://guide/{topic}`. It does not proxy any",
1151
1197
  "other provider. It is available from the moment the broker finishes starting.",
1152
1198
  "",
1199
+ "`broker://providers` and `broker://providers/<name>` can be subscribed to: a",
1200
+ "slot appearing, a provider attaching or detaching, a slot joining or leaving",
1201
+ "`_all` each send `notifications/resources/updated`. Counters (`pendingCount`,",
1202
+ "`clientCount`, `sessionCount`) never do, since reading moves them; read them",
1203
+ "when you need them. A slot name is percent-encoded in its URI, so slot `a/b`",
1204
+ "is `broker://providers/a%2Fb`.",
1205
+ "",
1153
1206
  "### `_all`",
1154
1207
  "",
1155
1208
  "An aggregate, not a proxy. It presents the union of the tools and prompts of",
@@ -1183,6 +1236,26 @@ var GUIDE_CONNECT_CLIENT = [
1183
1236
  "changes its catalog. A client that subscribes sees a provider that arrives",
1184
1237
  "mid-session without reconnecting.",
1185
1238
  "",
1239
+ "### Subscribing to resources",
1240
+ "",
1241
+ "On any slot but `_all`, `resources/subscribe` and `resources/unsubscribe` work on",
1242
+ "every transport (WebSocket, SSE, Streamable HTTP, stdio). The broker keeps the",
1243
+ "subscriptions per session and sends the provider one subscribe per URI, so N",
1244
+ "clients cost the provider one. `notifications/resources/updated` reaches only",
1245
+ "the sessions subscribed to that exact URI (no normalization); every other",
1246
+ "notification is still broadcast to the whole slot.",
1247
+ "",
1248
+ "- Subscribing twice is one subscription; unsubscribing from something you do",
1249
+ " not hold succeeds.",
1250
+ "- Closing the socket, the SSE stream, or sending `DELETE /<slot>/mcp` releases",
1251
+ " everything the session held. A Streamable HTTP session that is never deleted",
1252
+ " never expires, and neither do its subscriptions.",
1253
+ "- Limits: 64 URIs per client, 1024 client subscriptions per slot, URIs of at",
1254
+ " most 2048 characters (`resourceSubscriptions` in the config). Past a limit the",
1255
+ " answer is `-32000`, an overlong URI `-32602`.",
1256
+ "- If the provider disconnects, subscriptions are kept and restored when a",
1257
+ " provider comes back, followed by one `updated` per URI so you re-read.",
1258
+ "",
1186
1259
  "## 4. Browser clients and the origin check",
1187
1260
  "",
1188
1261
  "All three HTTP client endpoints, `/<slot>/mcp`, `/<slot>/sse` and",
@@ -1444,7 +1517,8 @@ var GUIDE_DEPLOY = [
1444
1517
  ' "stdioUpstreams": [',
1445
1518
  ' { "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], "aggregate": true }',
1446
1519
  " ],",
1447
- ' "mcpServers": [{ "name": "geo", "url": "https://geo.example.com/mcp" }]',
1520
+ ' "mcpServers": [{ "name": "geo", "url": "https://geo.example.com/mcp" }],',
1521
+ ' "resourceSubscriptions": { "maxSubscriptionsPerClient": 64, "maxSubscriptionsPerSlot": 1024, "maxResourceUriLength": 2048 }',
1448
1522
  "}",
1449
1523
  "```",
1450
1524
  "",
@@ -1513,6 +1587,9 @@ var GUIDE_TROUBLESHOOTING = [
1513
1587
  "| 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 |",
1514
1588
  "| `-32601 Method not found` on `_all` for `resources/list` | `_all` covers tools and prompts only | connect to the provider's own slot for resources |",
1515
1589
  "| `-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 |",
1590
+ "| 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 |",
1591
+ "| `-32000 Subscription limit reached` | `maxSubscriptionsPerClient` or `maxSubscriptionsPerSlot` | unsubscribe what you no longer watch; raise the limit in `resourceSubscriptions` |",
1592
+ "| `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 |",
1516
1593
  "",
1517
1594
  "## The transport/path mismatch, in detail",
1518
1595
  "",
@@ -1588,7 +1665,8 @@ var GUIDE_TROUBLESHOOTING = [
1588
1665
  "",
1589
1666
  "Streamable HTTP and SSE sessions do not expire. A client that closes its tab",
1590
1667
  "without sending `DELETE /<slot>/mcp` leaves its session alive, and every",
1591
- "notification the provider broadcasts is queued into it, without bound.",
1668
+ "notification the provider broadcasts is queued into it, without bound, and",
1669
+ "every resource subscription it took stays held, pinning the provider's own.",
1592
1670
  "",
1593
1671
  "The broker cannot tell a live session from an abandoned one, so no single",
1594
1672
  "reading proves a leak. `sessionCount` in `provider_status` growing monotonically",
@@ -2224,6 +2302,11 @@ var ResourcePathPattern = class _ResourcePathPattern {
2224
2302
  var STATIC_CAPABILITIES = {
2225
2303
  "resources/list": "mcp.resources.read",
2226
2304
  "resources/read": "mcp.resources.read",
2305
+ "resources/templates/list": "mcp.resources.read",
2306
+ // Subscribing is reading over time. Unsubscribing is deliberately absent:
2307
+ // an unclassified method is not refused, and a client whose grant was
2308
+ // revoked must still be able to drop what it holds.
2309
+ "resources/subscribe": "mcp.resources.read",
2227
2310
  "tools/list": "mcp.tools.list",
2228
2311
  "prompts/list": "mcp.prompts.read",
2229
2312
  "prompts/get": "mcp.prompts.read",
@@ -2234,6 +2317,7 @@ var STATIC_CAPABILITIES = {
2234
2317
  "notifications/prompts/list_changed": "mcp.prompts.read"
2235
2318
  };
2236
2319
  var BROKER_READ_TOOLS = /* @__PURE__ */ new Set(["broker_info", "providers_list", "provider_status"]);
2320
+ var BROKER_READ_METHODS = /* @__PURE__ */ new Set(["tools/list", "resources/list", "resources/read", "resources/templates/list", "resources/subscribe", "notifications/resources/updated"]);
2237
2321
  function toolNameFrom(params) {
2238
2322
  if (typeof params !== "object" || params === null || Array.isArray(params)) return void 0;
2239
2323
  const name = params["name"];
@@ -2269,7 +2353,7 @@ var ConfiguredCapabilityClassifier = class {
2269
2353
  const method = operation.method;
2270
2354
  if (!method) return void 0;
2271
2355
  if (provider === "_broker") {
2272
- if (method === "tools/list" || method === "resources/list" || method === "resources/read") {
2356
+ if (BROKER_READ_METHODS.has(method)) {
2273
2357
  return { capability: "broker.providers.read" };
2274
2358
  }
2275
2359
  if (method === "tools/call") {
@@ -2928,6 +3012,11 @@ var AggregateServer = class _AggregateServer {
2928
3012
  onOpen = null;
2929
3013
  onClose = null;
2930
3014
  onError = null;
3015
+ /**
3016
+ * Called with a slot name whenever it joins or leaves the aggregate, so the
3017
+ * broker can announce the change on `broker://providers`.
3018
+ */
3019
+ onMembershipChanged = null;
2931
3020
  constructor(openClient) {
2932
3021
  this._openClient = openClient;
2933
3022
  }
@@ -2988,6 +3077,7 @@ var AggregateServer = class _AggregateServer {
2988
3077
  if (name === _AggregateServer.SLOT || this._sessions.has(name)) return;
2989
3078
  const session = new ProviderClientSession(name, this._openClient(name));
2990
3079
  this._sessions.set(name, session);
3080
+ this.onMembershipChanged?.(name);
2991
3081
  session.onCatalogChanged = () => {
2992
3082
  this._catalog.setProvider(name, { tools: session.tools, prompts: session.prompts });
2993
3083
  this._emitListChanged();
@@ -3019,6 +3109,7 @@ var AggregateServer = class _AggregateServer {
3019
3109
  session.close();
3020
3110
  this._catalog.removeProvider(name);
3021
3111
  this._emitListChanged();
3112
+ this.onMembershipChanged?.(name);
3022
3113
  }
3023
3114
  _subjectFor(principal) {
3024
3115
  if (principal?.subject) return principal.subject;
@@ -3476,6 +3567,263 @@ var pkg = require2("../package.json");
3476
3567
  var VERSION = pkg.version;
3477
3568
  var PACKAGE_NAME = pkg.name;
3478
3569
 
3570
+ // src/subscriptions/resource.subscription.registry.ts
3571
+ var DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS = Object.freeze({
3572
+ maxSubscriptionsPerClient: 64,
3573
+ maxSubscriptionsPerSlot: 1024,
3574
+ maxResourceUriLength: 2048
3575
+ });
3576
+ var SUBSCRIPTION_LIMIT_ERROR_CODE = -32e3;
3577
+ var OK = Object.freeze({ ok: true });
3578
+ var ResourceSubscriptionRegistry = class {
3579
+ _upstream;
3580
+ _limits;
3581
+ /** slot → uri → entry. */
3582
+ _slots = /* @__PURE__ */ new Map();
3583
+ /**
3584
+ * client → slot → URIs, counting subscriptions still being confirmed.
3585
+ * Counting those is what makes the per-client limit hold under concurrent
3586
+ * requests: a reservation is taken before the upstream call, not after.
3587
+ */
3588
+ _clients = /* @__PURE__ */ new Map();
3589
+ /** slot → number of client/URI pairs, reserved ones included. */
3590
+ _slotCounts = /* @__PURE__ */ new Map();
3591
+ constructor(upstream, limits = {}) {
3592
+ this._upstream = upstream;
3593
+ this._limits = { ...DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS, ...limits };
3594
+ }
3595
+ get limits() {
3596
+ return this._limits;
3597
+ }
3598
+ /**
3599
+ * Subscribes `client` to `uri` on `slot`. Idempotent: subscribing twice
3600
+ * keeps one entry and answers success.
3601
+ */
3602
+ subscribe(slot, client, sink, uri) {
3603
+ if (uri.length === 0) return Promise.resolve(failure(-32602, "Missing required parameter: uri"));
3604
+ if (uri.length > this._limits.maxResourceUriLength) {
3605
+ return Promise.resolve(
3606
+ failure(-32602, `Resource URI is ${uri.length} characters long; this broker accepts at most ${this._limits.maxResourceUriLength} (maxResourceUriLength).`)
3607
+ );
3608
+ }
3609
+ const alreadyHeld = this._clients.get(client)?.get(slot)?.has(uri) ?? false;
3610
+ if (!alreadyHeld) {
3611
+ if (this._clientTotal(client) >= this._limits.maxSubscriptionsPerClient) {
3612
+ return Promise.resolve(
3613
+ failure(
3614
+ SUBSCRIPTION_LIMIT_ERROR_CODE,
3615
+ `Subscription limit reached: this client already holds ${this._limits.maxSubscriptionsPerClient} resource subscriptions (maxSubscriptionsPerClient). Unsubscribe from one first.`
3616
+ )
3617
+ );
3618
+ }
3619
+ if ((this._slotCounts.get(slot) ?? 0) >= this._limits.maxSubscriptionsPerSlot) {
3620
+ return Promise.resolve(
3621
+ failure(
3622
+ SUBSCRIPTION_LIMIT_ERROR_CODE,
3623
+ `Subscription limit reached on slot "${slot}": it already holds ${this._limits.maxSubscriptionsPerSlot} client subscriptions (maxSubscriptionsPerSlot).`
3624
+ )
3625
+ );
3626
+ }
3627
+ this._reserve(slot, client, uri);
3628
+ }
3629
+ const entry = this._entry(slot, uri, true);
3630
+ return this._enqueue(entry, async () => {
3631
+ if (!this._isReserved(slot, client, uri)) return OK;
3632
+ if (entry.state === "active") {
3633
+ entry.subscribers.set(client, sink);
3634
+ return OK;
3635
+ }
3636
+ entry.state = "subscribing";
3637
+ const outcome = await this._upstream.request(slot, "resources/subscribe", uri);
3638
+ if (outcome.ok) {
3639
+ entry.state = "active";
3640
+ if (this._isReserved(slot, client, uri)) entry.subscribers.set(client, sink);
3641
+ return OK;
3642
+ }
3643
+ entry.state = "inactive";
3644
+ this._release(slot, client, uri);
3645
+ this._dropIfUnused(slot, uri, entry);
3646
+ return outcome;
3647
+ });
3648
+ }
3649
+ /**
3650
+ * Unsubscribes `client` from `uri`. Always succeeds, including for a URI
3651
+ * the client never subscribed to: the client asked not to be subscribed,
3652
+ * and it is not. The provider is told only when the last subscriber leaves,
3653
+ * and only when it is connected; its answer does not change ours.
3654
+ */
3655
+ unsubscribe(slot, client, uri) {
3656
+ if (!this._isReserved(slot, client, uri)) return Promise.resolve(OK);
3657
+ this._release(slot, client, uri);
3658
+ const entry = this._entry(slot, uri, false);
3659
+ if (!entry) return Promise.resolve(OK);
3660
+ return this._enqueue(entry, async () => {
3661
+ entry.subscribers.delete(client);
3662
+ if (entry.subscribers.size > 0 || this._hasReservation(slot, uri)) return OK;
3663
+ if (entry.state === "active" && this._upstream.isConnected(slot)) {
3664
+ entry.state = "unsubscribing";
3665
+ await this._upstream.request(slot, "resources/unsubscribe", uri);
3666
+ }
3667
+ entry.state = "inactive";
3668
+ this._dropIfUnused(slot, uri, entry);
3669
+ return OK;
3670
+ });
3671
+ }
3672
+ /** Confirmed subscribers of `uri` on `slot`, the only ones a notification may reach. */
3673
+ subscribers(slot, uri) {
3674
+ const entry = this._slots.get(slot)?.get(uri);
3675
+ if (!entry) return [];
3676
+ return [...entry.subscribers].map(([client, sink]) => ({ client, sink }));
3677
+ }
3678
+ /** Where `uri` stands with the provider behind `slot`. */
3679
+ stateOf(slot, uri) {
3680
+ return this._slots.get(slot)?.get(uri)?.state ?? "inactive";
3681
+ }
3682
+ /** Number of client/URI pairs on `slot`, subscriptions being confirmed included. */
3683
+ countFor(slot) {
3684
+ return this._slotCounts.get(slot) ?? 0;
3685
+ }
3686
+ /** `true` when `slot` has at least one URI someone is subscribed to. */
3687
+ hasSubscriptions(slot) {
3688
+ for (const entry of this._slots.get(slot)?.values() ?? []) {
3689
+ if (entry.subscribers.size > 0) return true;
3690
+ }
3691
+ return false;
3692
+ }
3693
+ /**
3694
+ * Drops every subscription `client` holds, on every slot. Safe to call
3695
+ * repeatedly and for a client that holds nothing.
3696
+ */
3697
+ async removeClient(client) {
3698
+ const perSlot = this._clients.get(client);
3699
+ if (!perSlot) return;
3700
+ const pending = [];
3701
+ for (const [slot, uris] of [...perSlot]) {
3702
+ for (const uri of [...uris]) pending.push(this.unsubscribe(slot, client, uri));
3703
+ }
3704
+ await Promise.all(pending);
3705
+ }
3706
+ /**
3707
+ * Records that the provider behind `slot` went away: whatever it had
3708
+ * subscribed is gone with it. Subscribers are kept, so {@link replay} can
3709
+ * restore them when a provider comes back.
3710
+ */
3711
+ providerDisconnected(slot) {
3712
+ for (const entry of this._slots.get(slot)?.values() ?? []) entry.state = "inactive";
3713
+ }
3714
+ /**
3715
+ * Re-sends one upstream `resources/subscribe` per URI that still has
3716
+ * subscribers, after a provider (re)attached to `slot`. A URI the new
3717
+ * provider refuses is dropped along with its subscribers, since nothing
3718
+ * will ever notify them; the result lists them so the caller can tell them.
3719
+ */
3720
+ async replay(slot) {
3721
+ const entries = [...this._slots.get(slot) ?? []].filter(([, entry]) => entry.subscribers.size > 0);
3722
+ return Promise.all(
3723
+ entries.map(
3724
+ ([uri, entry]) => this._enqueue(entry, async () => {
3725
+ const subscribers = [...entry.subscribers].map(([client, sink]) => ({ client, sink }));
3726
+ if (entry.state === "active" || entry.subscribers.size === 0) return { uri, outcome: OK, subscribers };
3727
+ entry.state = "subscribing";
3728
+ const outcome = await this._upstream.request(slot, "resources/subscribe", uri);
3729
+ if (outcome.ok) {
3730
+ entry.state = "active";
3731
+ } else {
3732
+ entry.state = "inactive";
3733
+ for (const { client } of subscribers) {
3734
+ entry.subscribers.delete(client);
3735
+ this._release(slot, client, uri);
3736
+ }
3737
+ this._dropIfUnused(slot, uri, entry);
3738
+ }
3739
+ return { uri, outcome, subscribers };
3740
+ })
3741
+ )
3742
+ );
3743
+ }
3744
+ /** Forgets everything without calling upstream. Used when the broker stops. */
3745
+ clear() {
3746
+ this._slots.clear();
3747
+ this._clients.clear();
3748
+ this._slotCounts.clear();
3749
+ }
3750
+ // -------------------------------------------------------------------------
3751
+ // Internals
3752
+ // -------------------------------------------------------------------------
3753
+ _enqueue(entry, op) {
3754
+ const run = entry.tail.then(op);
3755
+ entry.tail = run.then(
3756
+ () => void 0,
3757
+ () => void 0
3758
+ );
3759
+ return run;
3760
+ }
3761
+ _entry(slot, uri, create) {
3762
+ let uris = this._slots.get(slot);
3763
+ if (!uris) {
3764
+ if (!create) return void 0;
3765
+ uris = /* @__PURE__ */ new Map();
3766
+ this._slots.set(slot, uris);
3767
+ }
3768
+ let entry = uris.get(uri);
3769
+ if (!entry && create) {
3770
+ entry = { state: "inactive", subscribers: /* @__PURE__ */ new Map(), tail: Promise.resolve() };
3771
+ uris.set(uri, entry);
3772
+ }
3773
+ return entry;
3774
+ }
3775
+ /** Removes an entry nobody holds or waits for, once its queue is idle. */
3776
+ _dropIfUnused(slot, uri, entry) {
3777
+ if (entry.subscribers.size > 0 || this._hasReservation(slot, uri) || entry.state !== "inactive") return;
3778
+ const uris = this._slots.get(slot);
3779
+ if (uris?.get(uri) !== entry) return;
3780
+ uris.delete(uri);
3781
+ if (uris.size === 0) this._slots.delete(slot);
3782
+ }
3783
+ _reserve(slot, client, uri) {
3784
+ let perSlot = this._clients.get(client);
3785
+ if (!perSlot) {
3786
+ perSlot = /* @__PURE__ */ new Map();
3787
+ this._clients.set(client, perSlot);
3788
+ }
3789
+ let uris = perSlot.get(slot);
3790
+ if (!uris) {
3791
+ uris = /* @__PURE__ */ new Set();
3792
+ perSlot.set(slot, uris);
3793
+ }
3794
+ uris.add(uri);
3795
+ this._slotCounts.set(slot, (this._slotCounts.get(slot) ?? 0) + 1);
3796
+ }
3797
+ _release(slot, client, uri) {
3798
+ const perSlot = this._clients.get(client);
3799
+ const uris = perSlot?.get(slot);
3800
+ if (!uris?.delete(uri)) return;
3801
+ if (uris.size === 0) perSlot.delete(slot);
3802
+ if (perSlot.size === 0) this._clients.delete(client);
3803
+ const count = (this._slotCounts.get(slot) ?? 1) - 1;
3804
+ if (count > 0) this._slotCounts.set(slot, count);
3805
+ else this._slotCounts.delete(slot);
3806
+ }
3807
+ _isReserved(slot, client, uri) {
3808
+ return this._clients.get(client)?.get(slot)?.has(uri) ?? false;
3809
+ }
3810
+ /** `true` when some client reserved `uri` on `slot`, confirmed or not. */
3811
+ _hasReservation(slot, uri) {
3812
+ for (const perSlot of this._clients.values()) {
3813
+ if (perSlot.get(slot)?.has(uri)) return true;
3814
+ }
3815
+ return false;
3816
+ }
3817
+ _clientTotal(client) {
3818
+ let n = 0;
3819
+ for (const uris of this._clients.get(client)?.values() ?? []) n += uris.size;
3820
+ return n;
3821
+ }
3822
+ };
3823
+ function failure(code, message) {
3824
+ return { ok: false, error: { code, message } };
3825
+ }
3826
+
3479
3827
  // src/ws/ws.tunnel.ts
3480
3828
  var MIME = {
3481
3829
  ".html": "text/html; charset=utf-8",
@@ -3499,6 +3847,7 @@ function requestIdOf(frame) {
3499
3847
  return void 0;
3500
3848
  }
3501
3849
  var BROKER_REQUEST_ID_PREFIX = "brk-";
3850
+ var STDIO_CLIENT_KEY = "stdio";
3502
3851
  var DEFAULT_PROVIDER_HEARTBEAT_MS = 3e4;
3503
3852
  var DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 6e4;
3504
3853
  var MAX_CLOSE_REASON_BYTES = 123;
@@ -3644,6 +3993,24 @@ var WsTunnel = class {
3644
3993
  /** Provider principals captured during successful WebSocket upgrades. */
3645
3994
  _pendingProviderPrincipals = /* @__PURE__ */ new WeakMap();
3646
3995
  _providerPrincipals = /* @__PURE__ */ new WeakMap();
3996
+ /**
3997
+ * `resources/subscribe` bookkeeping for every slot: who is subscribed to
3998
+ * what, and the one upstream subscription per URI that stands for them.
3999
+ */
4000
+ _subscriptions;
4001
+ /** Stable id per raw WS client socket, the WS part of a {@link ClientKey}. */
4002
+ _wsClientIds = /* @__PURE__ */ new WeakMap();
4003
+ _nextWsClientId = 1;
4004
+ /** Stable id per in-process client, the internal part of a {@link ClientKey}. */
4005
+ _internalClientIds = /* @__PURE__ */ new WeakMap();
4006
+ _nextInternalClientId = 1;
4007
+ /** Emitter behind {@link onProvidersChanged}. */
4008
+ _providersChanged = createEventEmitter();
4009
+ /** Slots changed since the last {@link _providersChanged} batch went out. */
4010
+ _changedSlots = /* @__PURE__ */ new Set();
4011
+ _changedSlotsTimer = null;
4012
+ /** Slots already warned about for a malformed `notifications/resources/updated`. */
4013
+ _invalidUpdateWarnedProviders = /* @__PURE__ */ new Set();
3647
4014
  constructor(options) {
3648
4015
  this._options = options;
3649
4016
  this._authGuard = options.auth ? new HttpAuthGuard(options.auth, options.mcpPath ?? "/mcp") : null;
@@ -3651,6 +4018,16 @@ var WsTunnel = class {
3651
4018
  this._allowedOrigins = originPredicate(options.allowedOrigins);
3652
4019
  this._authorization = options.authorization ?? options.auth?.authorization ?? null;
3653
4020
  this._slotResourceResolver = options.slotResourceResolver ?? this._authorization?.slotResourceResolver ?? options.auth?.slotResourceResolver ?? new DefaultSlotResourceResolver();
4021
+ this._subscriptions = new ResourceSubscriptionRegistry(
4022
+ {
4023
+ request: (slot, method, uri) => this._subscriptionRequest(slot, method, uri),
4024
+ isConnected: (slot) => {
4025
+ const state = this._providers.get(slot);
4026
+ return state !== void 0 && this._isProviderConnected(slot, state);
4027
+ }
4028
+ },
4029
+ options.resourceSubscriptions
4030
+ );
3654
4031
  }
3655
4032
  // -------------------------------------------------------------------------
3656
4033
  // IBrokerContext implementation
@@ -3725,6 +4102,10 @@ var WsTunnel = class {
3725
4102
  providers: this._aggregateServer?.providerNames ?? []
3726
4103
  };
3727
4104
  }
4105
+ /** See {@link IBrokerContext.onProvidersChanged}. */
4106
+ get onProvidersChanged() {
4107
+ return this._providersChanged;
4108
+ }
3728
4109
  getProvidersInfo() {
3729
4110
  const out = [];
3730
4111
  for (const [name, state] of this._providers) {
@@ -3763,7 +4144,8 @@ var WsTunnel = class {
3763
4144
  connectedForMs: since !== null ? Math.max(0, Date.now() - since) : null,
3764
4145
  clientCount: state.wsClients.size,
3765
4146
  sessionCount: state.sseSessions.size + state.httpSessions.size,
3766
- pendingCount: state.pending.size
4147
+ pendingCount: state.pending.size,
4148
+ resourceSubscriptionCount: this._subscriptions.countFor(name)
3767
4149
  };
3768
4150
  }
3769
4151
  // -------------------------------------------------------------------------
@@ -3786,6 +4168,7 @@ var WsTunnel = class {
3786
4168
  const state = this._getOrCreateProviderState(name);
3787
4169
  this._loopbackProviders.set(name, transport);
3788
4170
  state.connectedSinceMs = Date.now();
4171
+ this._onProviderAttached(name);
3789
4172
  transport.onMessage = (data) => this._routeFromProvider(state, name, data);
3790
4173
  transport.onClose = () => {
3791
4174
  this._loopbackProviders.delete(name);
@@ -3809,6 +4192,7 @@ var WsTunnel = class {
3809
4192
  onClose: null,
3810
4193
  send: (message) => {
3811
4194
  if (closed) return;
4195
+ if (this._interceptClientFrame(state, providerName, message, { type: "internal", client })) return;
3812
4196
  if (this._isProviderConnected(providerName, state)) {
3813
4197
  this._sendToProvider(state, providerName, this._trackRequest(state, message, { type: "internal", client }));
3814
4198
  } else if (requestIdOf(message) !== void 0) {
@@ -3822,6 +4206,7 @@ var WsTunnel = class {
3822
4206
  for (const [brokerId, entry] of state.pending) {
3823
4207
  if (entry.sink.type === "internal" && entry.sink.client === client) state.pending.delete(brokerId);
3824
4208
  }
4209
+ void this._subscriptions.removeClient(this._internalClientKey(client));
3825
4210
  }
3826
4211
  };
3827
4212
  state.internalClients.add(client);
@@ -3922,6 +4307,7 @@ var WsTunnel = class {
3922
4307
  };
3923
4308
  upstream.onOpen = () => {
3924
4309
  this._getOrCreateProviderState(cfg.name).connectedSinceMs = Date.now();
4310
+ this._onProviderAttached(cfg.name);
3925
4311
  if (cfg.aggregate) void this._aggregateServer?.addProvider(cfg.name);
3926
4312
  };
3927
4313
  this._upstreams.set(cfg.name, upstream);
@@ -3941,6 +4327,7 @@ var WsTunnel = class {
3941
4327
  console.error(`[broker] stdio client transport: ${err.message}`);
3942
4328
  };
3943
4329
  transport.onClose = () => {
4330
+ void this._subscriptions.removeClient(STDIO_CLIENT_KEY);
3944
4331
  };
3945
4332
  transport.connect();
3946
4333
  }
@@ -4017,6 +4404,7 @@ var WsTunnel = class {
4017
4404
  const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
4018
4405
  server.setScopeFilter(this._options.auth?.aggregateScopeFilter ?? null);
4019
4406
  server.setPolicyAuthorization(this._authorization);
4407
+ server.onMembershipChanged = (name) => this._emitProviderChanged(name);
4020
4408
  server.start();
4021
4409
  this.registerLoopbackProvider(AggregateServer.SLOT, server);
4022
4410
  this._aggregateServer = server;
@@ -4031,6 +4419,10 @@ var WsTunnel = class {
4031
4419
  this._stopHeartbeat();
4032
4420
  this._stopRequestTimeoutSweep();
4033
4421
  this._providerSockets.clear();
4422
+ this._subscriptions.clear();
4423
+ if (this._changedSlotsTimer) clearTimeout(this._changedSlotsTimer);
4424
+ this._changedSlotsTimer = null;
4425
+ this._changedSlots.clear();
4034
4426
  const brokerServer = this._brokerServer;
4035
4427
  this._brokerServer = null;
4036
4428
  if (brokerServer) {
@@ -4281,6 +4673,9 @@ var WsTunnel = class {
4281
4673
  case "internal":
4282
4674
  sink.client.onMessage?.(data);
4283
4675
  return;
4676
+ case "broker":
4677
+ sink.resolve(data);
4678
+ return;
4284
4679
  }
4285
4680
  }
4286
4681
  /**
@@ -4735,6 +5130,7 @@ data: ${messagesUrl}?sessionId=${sessionId}
4735
5130
  for (const [brokerId, entry] of state.pending) {
4736
5131
  if (entry.sink.type === "sse" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
4737
5132
  }
5133
+ void this._subscriptions.removeClient(`sse:${sessionId}`);
4738
5134
  });
4739
5135
  }
4740
5136
  /**
@@ -4761,7 +5157,7 @@ data: ${messagesUrl}?sessionId=${sessionId}
4761
5157
  res.end(this._policyDeniedPayload(body));
4762
5158
  return;
4763
5159
  }
4764
- if (this._isProviderConnected(providerName, state)) {
5160
+ if (this._interceptClientFrame(state, providerName, body, { type: "sse", sessionId })) ; else if (this._isProviderConnected(providerName, state)) {
4765
5161
  this._sendToProvider(state, providerName, this._trackRequest(state, body, { type: "sse", sessionId }), principal);
4766
5162
  } else {
4767
5163
  const sseRes = state.sseSessions.get(sessionId);
@@ -4815,6 +5211,7 @@ data: ${messagesUrl}?sessionId=${sessionId}
4815
5211
  start: () => openTransport(transport),
4816
5212
  stop: () => {
4817
5213
  state.httpSessions.delete(sessionId);
5214
+ void this._subscriptions.removeClient(`http:${sessionId}`);
4818
5215
  for (const [brokerId, entry] of state.pending) {
4819
5216
  if (entry.sink.type === "http-session" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
4820
5217
  }
@@ -4830,6 +5227,7 @@ data: ${messagesUrl}?sessionId=${sessionId}
4830
5227
  session.transport.send(this._policyDeniedPayload(frame));
4831
5228
  return;
4832
5229
  }
5230
+ if (this._interceptClientFrame(state, providerName, frame, { type: "http-session", sessionId })) return;
4833
5231
  if (!this._isProviderConnected(providerName, state)) {
4834
5232
  session.transport.send(this._notConnectedPayload(providerName, frame));
4835
5233
  return;
@@ -4913,6 +5311,7 @@ data: ${data}
4913
5311
  state.ws = ws;
4914
5312
  state.connectedSinceMs = Date.now();
4915
5313
  this._watchProviderSocket(ws);
5314
+ this._onProviderAttached(name);
4916
5315
  if (providerPrincipal) {
4917
5316
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
4918
5317
  }
@@ -5052,6 +5451,7 @@ data: ${data}
5052
5451
  for (const [brokerId, entry] of state.pending) {
5053
5452
  if (entry.sink.type === "ws" && entry.sink.socket === ws) state.pending.delete(brokerId);
5054
5453
  }
5454
+ void this._subscriptions.removeClient(this._wsClientKey(ws));
5055
5455
  });
5056
5456
  ws.on("error", (err) => {
5057
5457
  console.error(`[broker] client socket error on slot "${providerName}": ${err.message}. The socket is being closed; the client should reconnect.`);
@@ -5121,6 +5521,7 @@ data: ${data}
5121
5521
  const state2 = this._getOrCreateProviderState(name);
5122
5522
  state2.ws = ws;
5123
5523
  state2.connectedSinceMs = Date.now();
5524
+ this._onProviderAttached(name);
5124
5525
  if (providerPrincipal) {
5125
5526
  this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
5126
5527
  }
@@ -5178,6 +5579,7 @@ data: ${data}
5178
5579
  }
5179
5580
  }
5180
5581
  _routeFromStdioClient(state, data) {
5582
+ if (this._interceptClientFrame(state, this._stdioClientProvider, data, { type: "stdio" })) return;
5181
5583
  if (this._isProviderConnected(this._stdioClientProvider, state)) {
5182
5584
  this._sendToProvider(state, this._stdioClientProvider, this._trackRequest(state, data, { type: "stdio" }));
5183
5585
  } else {
@@ -5206,6 +5608,7 @@ data: ${data}
5206
5608
  client.send(this._policyDeniedPayload(data));
5207
5609
  return;
5208
5610
  }
5611
+ if (this._interceptClientFrame(state, providerName, data, { type: "ws", socket: client })) return;
5209
5612
  if (!this._isProviderConnected(providerName, state)) {
5210
5613
  client.send(
5211
5614
  JSON.stringify({
@@ -5233,6 +5636,8 @@ data: ${data}
5233
5636
  } else {
5234
5637
  this._warnUnmatchedResponseId(providerName, msg.id);
5235
5638
  }
5639
+ } else if (msg.method === "notifications/resources/updated") {
5640
+ this._routeResourceUpdated(state, providerName, msg, data);
5236
5641
  } else {
5237
5642
  this._broadcast(state, providerName, data);
5238
5643
  }
@@ -5301,6 +5706,8 @@ data: ${data}
5301
5706
  */
5302
5707
  _failProviderDisconnected(state, name) {
5303
5708
  state.connectedSinceMs = null;
5709
+ this._subscriptions.providerDisconnected(name);
5710
+ this._emitProviderChanged(name);
5304
5711
  for (const entry of state.pending.values()) {
5305
5712
  const error = JSON.stringify({
5306
5713
  jsonrpc: "2.0",
@@ -5340,10 +5747,225 @@ data: ${data}
5340
5747
  httpEndpoint: null
5341
5748
  };
5342
5749
  this._providers.set(name, state);
5750
+ this._emitProviderChanged(name);
5343
5751
  }
5344
5752
  return state;
5345
5753
  }
5346
5754
  // -------------------------------------------------------------------------
5755
+ // Resource subscriptions
5756
+ // -------------------------------------------------------------------------
5757
+ /** The {@link ClientKey} of a raw WS client socket. */
5758
+ _wsClientKey(ws) {
5759
+ let id = this._wsClientIds.get(ws);
5760
+ if (id === void 0) {
5761
+ id = this._nextWsClientId++;
5762
+ this._wsClientIds.set(ws, id);
5763
+ }
5764
+ return `ws:${id}`;
5765
+ }
5766
+ /** The {@link ClientKey} of a sink that is a client, `null` for the broker's own and internal sinks. */
5767
+ _clientKeyOf(sink) {
5768
+ switch (sink.type) {
5769
+ case "ws":
5770
+ return this._wsClientKey(sink.socket);
5771
+ case "sse":
5772
+ return `sse:${sink.sessionId}`;
5773
+ case "http-session":
5774
+ return `http:${sink.sessionId}`;
5775
+ case "stdio":
5776
+ return STDIO_CLIENT_KEY;
5777
+ case "internal":
5778
+ return this._internalClientKey(sink.client);
5779
+ default:
5780
+ return null;
5781
+ }
5782
+ }
5783
+ /** The {@link ClientKey} of an in-process client from {@link openInternalClient}. */
5784
+ _internalClientKey(client) {
5785
+ let id = this._internalClientIds.get(client);
5786
+ if (id === void 0) {
5787
+ id = this._nextInternalClientId++;
5788
+ this._internalClientIds.set(client, id);
5789
+ }
5790
+ return `internal:${id}`;
5791
+ }
5792
+ /** The principal behind a client sink, for the per-notification policy check. */
5793
+ _principalOfSink(state, sink) {
5794
+ switch (sink.type) {
5795
+ case "ws":
5796
+ return this._clientPrincipals.get(sink.socket) ?? null;
5797
+ case "sse": {
5798
+ const res = state.sseSessions.get(sink.sessionId);
5799
+ return res ? this._streamPrincipals.get(res) ?? null : null;
5800
+ }
5801
+ case "http-session":
5802
+ return state.httpSessions.get(sink.sessionId)?.principal ?? null;
5803
+ default:
5804
+ return null;
5805
+ }
5806
+ }
5807
+ /**
5808
+ * Answers `resources/subscribe` and `resources/unsubscribe` from the
5809
+ * broker's own registry instead of relaying them. Returns `true` when the
5810
+ * frame was one of those and has been (or will be) answered.
5811
+ *
5812
+ * Also remembers the last `initialize` of the slot, for {@link _replaySubscriptions}.
5813
+ *
5814
+ * Runs after the policy check. `resources/unsubscribe` is not classified,
5815
+ * so it is never refused: a client whose read grant was revoked must
5816
+ * still be able to drop what it holds. `_all` is skipped: it serves tools
5817
+ * and prompts only, and answers resources methods with `-32601` itself.
5818
+ */
5819
+ _interceptClientFrame(state, providerName, frame, sink) {
5820
+ if (!frame.includes('"resources/') && !frame.includes('"initialize"')) return false;
5821
+ const msg = parseObjectFrame(frame);
5822
+ if (!msg) return false;
5823
+ if (msg.method === "initialize") {
5824
+ state.lastInitializeParams = msg.params;
5825
+ return false;
5826
+ }
5827
+ if (msg.method !== "resources/subscribe" && msg.method !== "resources/unsubscribe") return false;
5828
+ if (providerName === AggregateServer.SLOT) return false;
5829
+ const id = msg.id;
5830
+ if (typeof id !== "string" && typeof id !== "number") return false;
5831
+ const client = this._clientKeyOf(sink);
5832
+ if (!client) return false;
5833
+ const answer = (outcome) => {
5834
+ const reply = outcome.ok ? { jsonrpc: "2.0", id, result: {} } : { jsonrpc: "2.0", id, error: outcome.error };
5835
+ this._deliverToSink(state, sink, JSON.stringify(reply));
5836
+ };
5837
+ const uri = msg.params?.uri;
5838
+ if (typeof uri !== "string" || uri.length === 0) {
5839
+ answer({ ok: false, error: { code: -32602, message: "Missing required parameter: uri" } });
5840
+ return true;
5841
+ }
5842
+ const pending = msg.method === "resources/subscribe" ? this._subscriptions.subscribe(providerName, client, sink, uri) : this._subscriptions.unsubscribe(providerName, client, uri);
5843
+ void pending.then(answer, (error) => answer({ ok: false, error: { code: -32603, message: `Subscription failed: ${error.message}` } }));
5844
+ return true;
5845
+ }
5846
+ /**
5847
+ * Sends one aggregated `resources/subscribe` or `resources/unsubscribe` to
5848
+ * the provider behind `slot` and resolves with its answer. Never rejects:
5849
+ * a disconnect or a timeout arrives as an error answer through the same
5850
+ * pending-request machinery every client request uses.
5851
+ */
5852
+ _subscriptionRequest(slot, method, uri) {
5853
+ return this._brokerRequest(slot, method, { uri }).then((reply) => reply.error ? { ok: false, error: reply.error } : { ok: true });
5854
+ }
5855
+ /** Sends a request of the broker's own to a provider and resolves with the parsed answer. */
5856
+ _brokerRequest(slot, method, params) {
5857
+ const state = this._providers.get(slot);
5858
+ if (!state || !this._isProviderConnected(slot, state)) {
5859
+ return Promise.resolve({ error: { code: -32e3, message: `Provider "${slot}" not connected` } });
5860
+ }
5861
+ return new Promise((resolve5) => {
5862
+ const sink = {
5863
+ type: "broker",
5864
+ resolve: (frame) => {
5865
+ const reply = parseObjectFrame(frame);
5866
+ resolve5(reply ?? { error: { code: -32700, message: "Unparseable answer" } });
5867
+ }
5868
+ };
5869
+ this._sendToProvider(state, slot, this._trackRequest(state, JSON.stringify({ jsonrpc: "2.0", id: 0, method, params }), sink));
5870
+ });
5871
+ }
5872
+ /**
5873
+ * Delivers a provider's `notifications/resources/updated` to the sessions
5874
+ * subscribed to its URI, and to nobody else.
5875
+ *
5876
+ * A frame without a usable URI is dropped, never broadcast: there is no
5877
+ * safe audience for it. Each recipient is re-checked against the policy,
5878
+ * because a grant can be revoked after the subscription was accepted; a
5879
+ * recipient that fails the check is unsubscribed as well as skipped.
5880
+ */
5881
+ _routeResourceUpdated(state, providerName, msg, data) {
5882
+ const uri = msg.params?.uri;
5883
+ if (typeof uri !== "string" || uri.length === 0 || uri.length > this._subscriptions.limits.maxResourceUriLength) {
5884
+ if (!this._invalidUpdateWarnedProviders.has(providerName)) {
5885
+ this._invalidUpdateWarnedProviders.add(providerName);
5886
+ console.warn(
5887
+ `[broker] provider "${providerName}" sent notifications/resources/updated without a usable params.uri; it was dropped, not broadcast. The notification must name the one URI that changed, as a string of at most ${this._subscriptions.limits.maxResourceUriLength} characters. Further such frames from this slot are not logged.`
5888
+ );
5889
+ }
5890
+ return;
5891
+ }
5892
+ this._deliverResourceUpdated(state, providerName, uri, data);
5893
+ }
5894
+ _deliverResourceUpdated(state, providerName, uri, data) {
5895
+ for (const { client, sink } of this._subscriptions.subscribers(providerName, uri)) {
5896
+ if (sink.type !== "stdio" && sink.type !== "internal" && !this._authorizeMcpFrame(providerName, data, this._principalOfSink(state, sink))) {
5897
+ void this._subscriptions.unsubscribe(providerName, client, uri);
5898
+ continue;
5899
+ }
5900
+ this._deliverToSink(state, sink, data);
5901
+ }
5902
+ }
5903
+ /** A provider now serves `name`: announce it, and restore the subscriptions it should hold. */
5904
+ _onProviderAttached(name) {
5905
+ this._emitProviderChanged(name);
5906
+ if (this._subscriptions.hasSubscriptions(name)) {
5907
+ void this._replaySubscriptions(name).catch((error) => {
5908
+ console.error(`[broker] provider "${name}": restoring resource subscriptions failed: ${error.message}`);
5909
+ });
5910
+ }
5911
+ }
5912
+ /**
5913
+ * Re-subscribes a provider that (re)attached to URIs clients still hold,
5914
+ * once per URI.
5915
+ *
5916
+ * The provider is handshaken first, with the last `initialize` a client
5917
+ * sent on this slot, because a fresh provider has no session: requests
5918
+ * before `initialize` break the MCP lifecycle, and an mcp-core server
5919
+ * suppresses its list_changed notifications until it sees
5920
+ * `notifications/initialized`.
5921
+ *
5922
+ * Every subscriber then gets one `notifications/resources/updated`: the
5923
+ * content may have changed while nobody was watching, so a re-read is due.
5924
+ * Where the new provider refuses a URI, that is also what tells its
5925
+ * subscribers, since their re-read fails; their subscription is dropped.
5926
+ */
5927
+ async _replaySubscriptions(name) {
5928
+ const params = this._providers.get(name)?.lastInitializeParams ?? {
5929
+ protocolVersion: "2025-06-18",
5930
+ capabilities: {},
5931
+ clientInfo: { name: this.name, version: this.version }
5932
+ };
5933
+ const init = await this._brokerRequest(name, "initialize", params);
5934
+ if (init.error) {
5935
+ console.warn(`[broker] provider "${name}" refused the initialize sent before restoring subscriptions: ${init.error.message}. Subscriptions are restored anyway.`);
5936
+ } else {
5937
+ const state2 = this._providers.get(name);
5938
+ if (state2) this._sendToProvider(state2, name, JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
5939
+ }
5940
+ const state = this._providers.get(name);
5941
+ for (const result of await this._subscriptions.replay(name)) {
5942
+ if (!result.outcome.ok) {
5943
+ console.warn(
5944
+ `[broker] provider "${name}" refused to restore the subscription to "${result.uri}" after reconnecting: ${result.outcome.error.message}. Its ${result.subscribers.length} subscriber(s) were dropped and told to re-read.`
5945
+ );
5946
+ }
5947
+ if (!state) continue;
5948
+ const data = JSON.stringify({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { uri: result.uri } });
5949
+ for (const { sink } of result.subscribers) this._deliverToSink(state, sink, data);
5950
+ }
5951
+ }
5952
+ /**
5953
+ * Queues `name` for the next {@link onProvidersChanged} batch. Batched per
5954
+ * tick, so a multiplexed socket announcing ten slots, or a slot attaching
5955
+ * and joining `_all` in the same breath, produces one notification.
5956
+ */
5957
+ _emitProviderChanged(name) {
5958
+ this._changedSlots.add(name);
5959
+ if (this._changedSlotsTimer) return;
5960
+ this._changedSlotsTimer = setTimeout(() => {
5961
+ this._changedSlotsTimer = null;
5962
+ const names = [...this._changedSlots];
5963
+ this._changedSlots.clear();
5964
+ if (names.length > 0) this._providersChanged.emit(names);
5965
+ }, 0);
5966
+ this._changedSlotsTimer.unref?.();
5967
+ }
5968
+ // -------------------------------------------------------------------------
5347
5969
  // Samples index
5348
5970
  // -------------------------------------------------------------------------
5349
5971
  _handleSamplesIndex(res) {
@@ -5421,6 +6043,7 @@ var WsTunnelBuilder = class {
5421
6043
  _providerHeartbeatIntervalMs = void 0;
5422
6044
  _providerTakeover = void 0;
5423
6045
  _providerRequestTimeoutMs = void 0;
6046
+ _resourceSubscriptions = void 0;
5424
6047
  _staticMounts = [];
5425
6048
  _stdioUpstreams = [];
5426
6049
  _remoteUpstreams = [];
@@ -5575,6 +6198,15 @@ var WsTunnelBuilder = class {
5575
6198
  this._providerRequestTimeoutMs = timeoutMs;
5576
6199
  return this;
5577
6200
  }
6201
+ /**
6202
+ * Bounds what `resources/subscribe` can make the broker hold. Fields left
6203
+ * out keep their default: 64 URIs per client, 1024 client subscriptions per
6204
+ * slot, URIs of at most 2048 characters.
6205
+ */
6206
+ withResourceSubscriptionLimits(limits) {
6207
+ this._resourceSubscriptions = { ...this._resourceSubscriptions, ...limits };
6208
+ return this;
6209
+ }
5578
6210
  /**
5579
6211
  * Adds a static-file mount served over plain HTTP.
5580
6212
  * Can be called multiple times; longest-prefix match wins at runtime.
@@ -5722,6 +6354,7 @@ var WsTunnelBuilder = class {
5722
6354
  providerHeartbeatIntervalMs: this._providerHeartbeatIntervalMs,
5723
6355
  providerTakeover: this._providerTakeover,
5724
6356
  providerRequestTimeoutMs: this._providerRequestTimeoutMs,
6357
+ resourceSubscriptions: this._resourceSubscriptions,
5725
6358
  staticMounts: this._staticMounts.length > 0 ? [...this._staticMounts] : void 0,
5726
6359
  stdioUpstreams: this._stdioUpstreams.length > 0 ? [...this._stdioUpstreams] : void 0,
5727
6360
  remoteUpstreams: this._remoteUpstreams.length > 0 ? [...this._remoteUpstreams] : void 0,
@@ -5985,6 +6618,6 @@ function resolveOpenTarget(raw, baseUrl) {
5985
6618
  return { url: resolved.toString(), path: resolved.pathname };
5986
6619
  }
5987
6620
 
5988
- 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 };
5989
- //# sourceMappingURL=chunk-J5TN5RYU.js.map
5990
- //# sourceMappingURL=chunk-J5TN5RYU.js.map
6621
+ 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, DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, ResourceSubscriptionRegistry, SUBSCRIPTION_LIMIT_ERROR_CODE, 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 };
6622
+ //# sourceMappingURL=chunk-YTRVLPHP.js.map
6623
+ //# sourceMappingURL=chunk-YTRVLPHP.js.map