@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
|
@@ -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
|
|
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-
|
|
5990
|
-
//# sourceMappingURL=chunk-
|
|
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
|