@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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _cyanmycelium_mcp_core from '@cyanmycelium/mcp-core';
2
- import { McpBehavior, McpResource, McpTool, McpResourceTemplate, GrammarResolverOptions, IMcpServer, IMessageTransport, McpGrammar, IAccessTokenClaims, IMcpPrincipal, McpAuthError, ITokenValidator, IProtectedResourceMetadata, McpAdapterBase, McpResourceContent, McpToolResult } from '@cyanmycelium/mcp-core';
2
+ import { IEventSource, McpBehavior, McpResourceContent, McpResource, McpTool, McpResourceTemplate, GrammarResolverOptions, IMcpServer, IMessageTransport, McpGrammar, IAccessTokenClaims, IMcpPrincipal, McpAuthError, ITokenValidator, IProtectedResourceMetadata, McpAdapterBase, McpToolResult } from '@cyanmycelium/mcp-core';
3
3
  export { IAccessTokenClaims, IProtectedResourceMetadata, ITokenValidator } from '@cyanmycelium/mcp-core';
4
4
  import { IncomingMessage, ServerResponse } from 'http';
5
5
 
@@ -56,6 +56,18 @@ interface IBrokerContext {
56
56
  * a slot that cannot exist at host start.
57
57
  */
58
58
  getStdioBridgeTarget?(): string | null | undefined;
59
+ /**
60
+ * Fires with the names of the slots whose *state* changed: a slot
61
+ * appeared, a provider attached or detached, a slot joined or left `_all`.
62
+ * Changes in the same tick arrive as one batch.
63
+ *
64
+ * Counters (`pendingCount`, `clientCount`, `sessionCount`) deliberately do
65
+ * not fire it: reading a resource moves them, so notifying on them would
66
+ * make every read trigger the next notification. They stay readable on
67
+ * demand. `_broker` turns this into `notifications/resources/updated` on
68
+ * `broker://providers` and `broker://providers/<name>`.
69
+ */
70
+ readonly onProvidersChanged?: IEventSource<readonly string[]>;
59
71
  }
60
72
  /**
61
73
  * Membership snapshot of the reserved `_all` aggregate slot.
@@ -123,6 +135,13 @@ interface IBrokerProviderInfo {
123
135
  sessionCount: number;
124
136
  /** Number of in-flight JSON-RPC requests awaiting a response. */
125
137
  pendingCount: number;
138
+ /**
139
+ * Client/URI pairs held by `resources/subscribe` on this slot. A number
140
+ * that only grows is the signature of Streamable HTTP clients leaving
141
+ * without `DELETE`: their sessions, and so their subscriptions, never
142
+ * expire. Optional so hand-written contexts keep compiling.
143
+ */
144
+ resourceSubscriptionCount?: number;
126
145
  }
127
146
  /** @deprecated Use {@link IBrokerContext}. */
128
147
  type BrokerContext = IBrokerContext;
@@ -138,6 +157,12 @@ type BrokerProviderInfo = IBrokerProviderInfo;
138
157
  declare class BrokerInfoBehavior extends McpBehavior {
139
158
  static readonly NAMESPACE = "broker";
140
159
  constructor(context: IBrokerContext);
160
+ /**
161
+ * Always reads live. `McpBehavior` caches the content of its root
162
+ * resource on first read and never refreshes it, which suits static
163
+ * content but froze this snapshot at whatever the first reader saw.
164
+ */
165
+ readResourceAsync(uri: string): Promise<McpResourceContent | undefined>;
141
166
  protected _buildResources(): McpResource[];
142
167
  protected _buildTools(): McpTool[];
143
168
  }
@@ -154,6 +179,12 @@ declare class BrokerInfoBehavior extends McpBehavior {
154
179
  declare class BrokerProvidersBehavior extends McpBehavior {
155
180
  static readonly NAMESPACE = "broker_providers";
156
181
  constructor(context: IBrokerContext);
182
+ /**
183
+ * Always reads live. `McpBehavior` caches the content of its root
184
+ * resource on first read and never refreshes it, which suits static
185
+ * content but froze this snapshot at whatever the first reader saw.
186
+ */
187
+ readResourceAsync(uri: string): Promise<McpResourceContent | undefined>;
157
188
  protected _buildResources(): McpResource[];
158
189
  protected _buildTemplate(): McpResourceTemplate[];
159
190
  protected _buildTools(): McpTool[];
@@ -1022,6 +1053,151 @@ type ProviderPrincipal = IProviderPrincipal;
1022
1053
  /** @deprecated Use {@link IProviderAuthenticator}. */
1023
1054
  type ProviderAuthenticator = IProviderAuthenticator;
1024
1055
 
1056
+ /**
1057
+ * Per-slot bookkeeping for `resources/subscribe`.
1058
+ *
1059
+ * A slot is one MCP server shared by every client attached to it, so the
1060
+ * provider behind it must not see one subscription per browser tab: it sees
1061
+ * one per URI. This registry owns that reference count. The first subscriber
1062
+ * of a URI triggers the upstream `resources/subscribe`, the last one to leave
1063
+ * triggers the upstream `resources/unsubscribe`, and everybody in between is
1064
+ * answered locally.
1065
+ *
1066
+ * Every operation on one `(slot, uri)` pair runs through a queue, so two
1067
+ * clients subscribing at the same instant cannot both send upstream, and an
1068
+ * unsubscribe cannot overtake the subscribe it undoes. A second subscriber that
1069
+ * arrives while the first upstream round trip is in flight waits for it: on
1070
+ * success it is added locally, with no second upstream call; on failure it
1071
+ * asks the provider itself, since the first refusal may have been transient.
1072
+ *
1073
+ * The registry does no I/O. The tunnel supplies the upstream calls through
1074
+ * {@link IResourceSubscriptionUpstream} and decides what a subscriber is: `S`
1075
+ * is whatever it needs to deliver a notification later.
1076
+ */
1077
+ /** Identifies one consumer across transports, e.g. `ws:7`, `http:<session>`, `stdio`. */
1078
+ type ClientKey = string;
1079
+ /** An upstream answer, reduced to what the registry needs. */
1080
+ type SubscriptionOutcome = {
1081
+ readonly ok: true;
1082
+ } | {
1083
+ readonly ok: false;
1084
+ readonly error: {
1085
+ readonly code: number;
1086
+ readonly message: string;
1087
+ };
1088
+ };
1089
+ /** What the tunnel provides so the registry can talk to a provider. */
1090
+ interface IResourceSubscriptionUpstream {
1091
+ /** Sends one request to the provider behind `slot` and resolves with its answer, never rejects. */
1092
+ request(slot: string, method: "resources/subscribe" | "resources/unsubscribe", uri: string): Promise<SubscriptionOutcome>;
1093
+ /** `true` when a provider currently serves `slot`. */
1094
+ isConnected(slot: string): boolean;
1095
+ }
1096
+ /**
1097
+ * Bounds on what clients can make the broker hold. Without them one client, or
1098
+ * many sessions a client never closes, could grow the registry without limit
1099
+ * and pin upstream subscriptions forever.
1100
+ */
1101
+ interface IResourceSubscriptionLimits {
1102
+ /** Distinct URIs one client may be subscribed to at once. */
1103
+ readonly maxSubscriptionsPerClient: number;
1104
+ /** Client/URI pairs one slot may hold at once. */
1105
+ readonly maxSubscriptionsPerSlot: number;
1106
+ /** Longest URI accepted, in UTF-16 code units. */
1107
+ readonly maxResourceUriLength: number;
1108
+ }
1109
+ declare const DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS: IResourceSubscriptionLimits;
1110
+ /**
1111
+ * Where a URI stands with the provider.
1112
+ *
1113
+ * - `inactive`: not subscribed upstream. Either nothing was ever sent, or the
1114
+ * provider disconnected and the subscription waits for {@link ResourceSubscriptionRegistry.replay}.
1115
+ * - `subscribing` / `unsubscribing`: an upstream round trip is in flight.
1116
+ * - `active`: the provider confirmed the subscription.
1117
+ */
1118
+ type SubscriptionState = "inactive" | "subscribing" | "active" | "unsubscribing";
1119
+ /** Error code for a subscription refused by a broker limit. JSON-RPC reserves -32000..-32099 for servers. */
1120
+ declare const SUBSCRIPTION_LIMIT_ERROR_CODE = -32000;
1121
+ /** One URI whose upstream subscription was re-sent after a provider reconnected. */
1122
+ interface IReplayResult<S> {
1123
+ readonly uri: string;
1124
+ readonly outcome: SubscriptionOutcome;
1125
+ /** Who was subscribed at the time; they are dropped when the outcome is a failure. */
1126
+ readonly subscribers: ReadonlyArray<{
1127
+ readonly client: ClientKey;
1128
+ readonly sink: S;
1129
+ }>;
1130
+ }
1131
+ declare class ResourceSubscriptionRegistry<S> {
1132
+ private readonly _upstream;
1133
+ private readonly _limits;
1134
+ /** slot → uri → entry. */
1135
+ private readonly _slots;
1136
+ /**
1137
+ * client → slot → URIs, counting subscriptions still being confirmed.
1138
+ * Counting those is what makes the per-client limit hold under concurrent
1139
+ * requests: a reservation is taken before the upstream call, not after.
1140
+ */
1141
+ private readonly _clients;
1142
+ /** slot → number of client/URI pairs, reserved ones included. */
1143
+ private readonly _slotCounts;
1144
+ constructor(upstream: IResourceSubscriptionUpstream, limits?: Partial<IResourceSubscriptionLimits>);
1145
+ get limits(): IResourceSubscriptionLimits;
1146
+ /**
1147
+ * Subscribes `client` to `uri` on `slot`. Idempotent: subscribing twice
1148
+ * keeps one entry and answers success.
1149
+ */
1150
+ subscribe(slot: string, client: ClientKey, sink: S, uri: string): Promise<SubscriptionOutcome>;
1151
+ /**
1152
+ * Unsubscribes `client` from `uri`. Always succeeds, including for a URI
1153
+ * the client never subscribed to: the client asked not to be subscribed,
1154
+ * and it is not. The provider is told only when the last subscriber leaves,
1155
+ * and only when it is connected; its answer does not change ours.
1156
+ */
1157
+ unsubscribe(slot: string, client: ClientKey, uri: string): Promise<SubscriptionOutcome>;
1158
+ /** Confirmed subscribers of `uri` on `slot`, the only ones a notification may reach. */
1159
+ subscribers(slot: string, uri: string): ReadonlyArray<{
1160
+ readonly client: ClientKey;
1161
+ readonly sink: S;
1162
+ }>;
1163
+ /** Where `uri` stands with the provider behind `slot`. */
1164
+ stateOf(slot: string, uri: string): SubscriptionState;
1165
+ /** Number of client/URI pairs on `slot`, subscriptions being confirmed included. */
1166
+ countFor(slot: string): number;
1167
+ /** `true` when `slot` has at least one URI someone is subscribed to. */
1168
+ hasSubscriptions(slot: string): boolean;
1169
+ /**
1170
+ * Drops every subscription `client` holds, on every slot. Safe to call
1171
+ * repeatedly and for a client that holds nothing.
1172
+ */
1173
+ removeClient(client: ClientKey): Promise<void>;
1174
+ /**
1175
+ * Records that the provider behind `slot` went away: whatever it had
1176
+ * subscribed is gone with it. Subscribers are kept, so {@link replay} can
1177
+ * restore them when a provider comes back.
1178
+ */
1179
+ providerDisconnected(slot: string): void;
1180
+ /**
1181
+ * Re-sends one upstream `resources/subscribe` per URI that still has
1182
+ * subscribers, after a provider (re)attached to `slot`. A URI the new
1183
+ * provider refuses is dropped along with its subscribers, since nothing
1184
+ * will ever notify them; the result lists them so the caller can tell them.
1185
+ */
1186
+ replay(slot: string): Promise<IReplayResult<S>[]>;
1187
+ /** Forgets everything without calling upstream. Used when the broker stops. */
1188
+ clear(): void;
1189
+ private _enqueue;
1190
+ private _entry;
1191
+ /** Removes an entry nobody holds or waits for, once its queue is idle. */
1192
+ private _dropIfUnused;
1193
+ private _reserve;
1194
+ private _release;
1195
+ private _isReserved;
1196
+ /** `true` when some client reserved `uri` on `slot`, confirmed or not. */
1197
+ private _hasReservation;
1198
+ private _clientTotal;
1199
+ }
1200
+
1025
1201
  /**
1026
1202
  * What the broker does when a provider connects to a slot another socket
1027
1203
  * already holds.
@@ -1199,6 +1375,18 @@ interface IWsTunnelOptions {
1199
1375
  * @default 60000
1200
1376
  */
1201
1377
  providerRequestTimeoutMs?: number;
1378
+ /**
1379
+ * Bounds on `resources/subscribe` bookkeeping. Every field is optional and
1380
+ * falls back to {@link DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS}: 64 URIs per
1381
+ * client, 1024 client subscriptions per slot, URIs of at most 2048
1382
+ * characters. A subscription past a limit is refused with `-32000`, an
1383
+ * overlong URI with `-32602`.
1384
+ *
1385
+ * The per-slot bound is what caps a Streamable HTTP client that closes its
1386
+ * tab without `DELETE`: its session never expires, so neither do its
1387
+ * subscriptions.
1388
+ */
1389
+ resourceSubscriptions?: Partial<IResourceSubscriptionLimits>;
1202
1390
  /**
1203
1391
  * Optional static-file mounts served over plain HTTP.
1204
1392
  * Matched by longest URL prefix; directory requests fall back to `index.html`.
@@ -1439,6 +1627,24 @@ declare class WsTunnel implements IBrokerContext {
1439
1627
  /** Provider principals captured during successful WebSocket upgrades. */
1440
1628
  private readonly _pendingProviderPrincipals;
1441
1629
  private readonly _providerPrincipals;
1630
+ /**
1631
+ * `resources/subscribe` bookkeeping for every slot: who is subscribed to
1632
+ * what, and the one upstream subscription per URI that stands for them.
1633
+ */
1634
+ private readonly _subscriptions;
1635
+ /** Stable id per raw WS client socket, the WS part of a {@link ClientKey}. */
1636
+ private readonly _wsClientIds;
1637
+ private _nextWsClientId;
1638
+ /** Stable id per in-process client, the internal part of a {@link ClientKey}. */
1639
+ private readonly _internalClientIds;
1640
+ private _nextInternalClientId;
1641
+ /** Emitter behind {@link onProvidersChanged}. */
1642
+ private readonly _providersChanged;
1643
+ /** Slots changed since the last {@link _providersChanged} batch went out. */
1644
+ private readonly _changedSlots;
1645
+ private _changedSlotsTimer;
1646
+ /** Slots already warned about for a malformed `notifications/resources/updated`. */
1647
+ private readonly _invalidUpdateWarnedProviders;
1442
1648
  constructor(options: IWsTunnelOptions);
1443
1649
  get version(): string;
1444
1650
  get name(): string;
@@ -1471,6 +1677,8 @@ declare class WsTunnel implements IBrokerContext {
1471
1677
  * `_all` by hand.
1472
1678
  */
1473
1679
  getAggregateInfo(): IBrokerAggregateInfo;
1680
+ /** See {@link IBrokerContext.onProvidersChanged}. */
1681
+ get onProvidersChanged(): IEventSource<readonly string[]>;
1474
1682
  getProvidersInfo(): IBrokerProviderInfo[];
1475
1683
  getProviderInfo(name: string): IBrokerProviderInfo | undefined;
1476
1684
  private _buildProviderInfo;
@@ -1890,6 +2098,71 @@ declare class WsTunnel implements IBrokerContext {
1890
2098
  private _isProviderConnected;
1891
2099
  /** Returns the state for `name`, creating it lazily if it doesn't exist yet. */
1892
2100
  private _getOrCreateProviderState;
2101
+ /** The {@link ClientKey} of a raw WS client socket. */
2102
+ private _wsClientKey;
2103
+ /** The {@link ClientKey} of a sink that is a client, `null` for the broker's own and internal sinks. */
2104
+ private _clientKeyOf;
2105
+ /** The {@link ClientKey} of an in-process client from {@link openInternalClient}. */
2106
+ private _internalClientKey;
2107
+ /** The principal behind a client sink, for the per-notification policy check. */
2108
+ private _principalOfSink;
2109
+ /**
2110
+ * Answers `resources/subscribe` and `resources/unsubscribe` from the
2111
+ * broker's own registry instead of relaying them. Returns `true` when the
2112
+ * frame was one of those and has been (or will be) answered.
2113
+ *
2114
+ * Also remembers the last `initialize` of the slot, for {@link _replaySubscriptions}.
2115
+ *
2116
+ * Runs after the policy check. `resources/unsubscribe` is not classified,
2117
+ * so it is never refused: a client whose read grant was revoked must
2118
+ * still be able to drop what it holds. `_all` is skipped: it serves tools
2119
+ * and prompts only, and answers resources methods with `-32601` itself.
2120
+ */
2121
+ private _interceptClientFrame;
2122
+ /**
2123
+ * Sends one aggregated `resources/subscribe` or `resources/unsubscribe` to
2124
+ * the provider behind `slot` and resolves with its answer. Never rejects:
2125
+ * a disconnect or a timeout arrives as an error answer through the same
2126
+ * pending-request machinery every client request uses.
2127
+ */
2128
+ private _subscriptionRequest;
2129
+ /** Sends a request of the broker's own to a provider and resolves with the parsed answer. */
2130
+ private _brokerRequest;
2131
+ /**
2132
+ * Delivers a provider's `notifications/resources/updated` to the sessions
2133
+ * subscribed to its URI, and to nobody else.
2134
+ *
2135
+ * A frame without a usable URI is dropped, never broadcast: there is no
2136
+ * safe audience for it. Each recipient is re-checked against the policy,
2137
+ * because a grant can be revoked after the subscription was accepted; a
2138
+ * recipient that fails the check is unsubscribed as well as skipped.
2139
+ */
2140
+ private _routeResourceUpdated;
2141
+ private _deliverResourceUpdated;
2142
+ /** A provider now serves `name`: announce it, and restore the subscriptions it should hold. */
2143
+ private _onProviderAttached;
2144
+ /**
2145
+ * Re-subscribes a provider that (re)attached to URIs clients still hold,
2146
+ * once per URI.
2147
+ *
2148
+ * The provider is handshaken first, with the last `initialize` a client
2149
+ * sent on this slot, because a fresh provider has no session: requests
2150
+ * before `initialize` break the MCP lifecycle, and an mcp-core server
2151
+ * suppresses its list_changed notifications until it sees
2152
+ * `notifications/initialized`.
2153
+ *
2154
+ * Every subscriber then gets one `notifications/resources/updated`: the
2155
+ * content may have changed while nobody was watching, so a re-read is due.
2156
+ * Where the new provider refuses a URI, that is also what tells its
2157
+ * subscribers, since their re-read fails; their subscription is dropped.
2158
+ */
2159
+ private _replaySubscriptions;
2160
+ /**
2161
+ * Queues `name` for the next {@link onProvidersChanged} batch. Batched per
2162
+ * tick, so a multiplexed socket announcing ten slots, or a slot attaching
2163
+ * and joining `_all` in the same breath, produces one notification.
2164
+ */
2165
+ private _emitProviderChanged;
1893
2166
  private _handleSamplesIndex;
1894
2167
  private _serveStatic;
1895
2168
  }
@@ -1925,6 +2198,7 @@ declare class WsTunnelBuilder {
1925
2198
  private _providerHeartbeatIntervalMs;
1926
2199
  private _providerTakeover;
1927
2200
  private _providerRequestTimeoutMs;
2201
+ private _resourceSubscriptions;
1928
2202
  private _staticMounts;
1929
2203
  private _stdioUpstreams;
1930
2204
  private _remoteUpstreams;
@@ -2040,6 +2314,12 @@ declare class WsTunnelBuilder {
2040
2314
  * @default 60000
2041
2315
  */
2042
2316
  withProviderRequestTimeout(timeoutMs: number): this;
2317
+ /**
2318
+ * Bounds what `resources/subscribe` can make the broker hold. Fields left
2319
+ * out keep their default: 64 URIs per client, 1024 client subscriptions per
2320
+ * slot, URIs of at most 2048 characters.
2321
+ */
2322
+ withResourceSubscriptionLimits(limits: Partial<IResourceSubscriptionLimits>): this;
2043
2323
  /**
2044
2324
  * Adds a static-file mount served over plain HTTP.
2045
2325
  * Can be called multiple times; longest-prefix match wins at runtime.
@@ -2541,6 +2821,17 @@ interface IBrokerConfig {
2541
2821
  * @default 60000
2542
2822
  */
2543
2823
  providerRequestTimeoutMs?: number;
2824
+ /**
2825
+ * Bounds on `resources/subscribe` bookkeeping. Each field maps to an env
2826
+ * var: `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT` (default 64),
2827
+ * `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT` (default 1024),
2828
+ * `MCP_BROKER_MAX_RESOURCE_URI_LENGTH` (default 2048).
2829
+ */
2830
+ resourceSubscriptions?: {
2831
+ maxSubscriptionsPerClient?: number;
2832
+ maxSubscriptionsPerSlot?: number;
2833
+ maxResourceUriLength?: number;
2834
+ };
2544
2835
  /**
2545
2836
  * What happens when a provider connects to a slot another socket already
2546
2837
  * holds. Maps to `MCP_BROKER_PROVIDER_TAKEOVER`.
@@ -2783,4 +3074,4 @@ type BrokerConfig = IBrokerConfig;
2783
3074
  /** @deprecated Use {@link ILoadedBrokerConfig}. */
2784
3075
  type LoadedBrokerConfig = ILoadedBrokerConfig;
2785
3076
 
2786
- export { type AccessTokenClaims, type AggregateScopeFilter, type AllowedOrigins, type AuditContext, AuthError, type AuthErrorCode, type AuthorizationAuditConfig, type AuthorizationAuditEvent, type AuthorizationDecision, type AuthorizationDecisionReason, type AuthorizationPolicyConfig, type AuthorizationRequest, type AuthorizationSubject, 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, type BrokerAuthConfig, type BrokerConfig, type BrokerContext, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, type BrokerDiagnosisRuleId, type BrokerDiagnosisSeverity, type BrokerGrammarEntry, BrokerGuideAdapter, BrokerGuideBehavior, type BrokerGuideTopic, BrokerInfoBehavior, type BrokerLocale, type BrokerProviderInfo, type BrokerProviderTransport, BrokerProvidersBehavior, type BrokerUserAgent, type CapabilityClassifier, type ClassifiedCapability, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, type DenyPolicy, HttpAuthGuard, type IAuditContext, type IAuthorizationAuditConfig, type IAuthorizationAuditEvent, type IAuthorizationDecision, type IAuthorizationPolicyConfig, type IAuthorizationRequest, type IAuthorizationSubject, type IBrokerAggregateInfo, type IBrokerAuthConfig, type IBrokerConfig, type IBrokerContext, type IBrokerDiagnosis, type IBrokerDiagnosisProblem, type IBrokerDiagnosisSkippedCheck, type IBrokerDiagnosisSlot, type IBrokerGrammarEntry, type IBrokerGuide, type IBrokerProviderInfo, type IBrokerSecurityInfo, type ICapabilityClassifier, type IClassifiedCapability, type IDenyPolicy, type IInternalClient, type IJwtAuthOptions, type IJwtValidatorOptions, type ILoadedBrokerConfig, type IMcpOperation, type IMcpbBundleConfig, type IOpenTargetResolution, type IPolicyAssignment, type IPolicyAuthorization, type IPolicyEngine, type IPrincipal, type IProviderAuthenticator, type IProviderPrincipal, type IProviderPublishDecision, type IRemoteUpstreamConfig, type IResolvedAuth, type IRoleDefinition, type ISlotResourceResolver, type IStartBrokerServerOptions, type IStaticMount, type IStdioUpstreamConfig, type ISubjectMapper, type ISubjectMappingConfig, type IUpstream, type IWsTunnelOptions, type InternalClient, type JwtAuthOptions, JwtSubjectMapper, JwtTokenValidator, type JwtValidatorOptions, type LoadedBrokerConfig, type McpOperation, type McpbBundleConfig, PACKAGE_NAME, type PolicyAssignment, type PolicyAuthorization, type PolicyEngine, type Principal, type ProtectedResourceMetadata, type ProviderAuthenticationResult, type ProviderAuthenticator, type ProviderAuthenticatorReturn, type ProviderPrincipal, type ProviderPublishDenialReason, type ProviderTakeoverMode, RemoteUpstream, type RemoteUpstreamConfig, type ResolvedAuth, ResourcePath, ResourcePathPattern, type RoleDefinition, SharedSecretProviderAuthenticator, type SlotResourceResolver, type StartBrokerServerOptions, type StaticMount, StdioUpstream, type StdioUpstreamConfig, type SubjectMapper, type SubjectMappingConfig, SubjectMappingError, type TokenValidator, type Upstream, VERSION, WsTunnel, WsTunnelBuilder, type WsTunnelOptions, 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 };
3077
+ export { type AccessTokenClaims, type AggregateScopeFilter, type AllowedOrigins, type AuditContext, AuthError, type AuthErrorCode, type AuthorizationAuditConfig, type AuthorizationAuditEvent, type AuthorizationDecision, type AuthorizationDecisionReason, type AuthorizationPolicyConfig, type AuthorizationRequest, type AuthorizationSubject, 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, type BrokerAuthConfig, type BrokerConfig, type BrokerContext, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, type BrokerDiagnosisRuleId, type BrokerDiagnosisSeverity, type BrokerGrammarEntry, BrokerGuideAdapter, BrokerGuideBehavior, type BrokerGuideTopic, BrokerInfoBehavior, type BrokerLocale, type BrokerProviderInfo, type BrokerProviderTransport, BrokerProvidersBehavior, type BrokerUserAgent, type CapabilityClassifier, type ClassifiedCapability, type ClientKey, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DEFAULT_RESOURCE_SUBSCRIPTION_LIMITS, DefaultSlotResourceResolver, type DenyPolicy, HttpAuthGuard, type IAuditContext, type IAuthorizationAuditConfig, type IAuthorizationAuditEvent, type IAuthorizationDecision, type IAuthorizationPolicyConfig, type IAuthorizationRequest, type IAuthorizationSubject, type IBrokerAggregateInfo, type IBrokerAuthConfig, type IBrokerConfig, type IBrokerContext, type IBrokerDiagnosis, type IBrokerDiagnosisProblem, type IBrokerDiagnosisSkippedCheck, type IBrokerDiagnosisSlot, type IBrokerGrammarEntry, type IBrokerGuide, type IBrokerProviderInfo, type IBrokerSecurityInfo, type ICapabilityClassifier, type IClassifiedCapability, type IDenyPolicy, type IInternalClient, type IJwtAuthOptions, type IJwtValidatorOptions, type ILoadedBrokerConfig, type IMcpOperation, type IMcpbBundleConfig, type IOpenTargetResolution, type IPolicyAssignment, type IPolicyAuthorization, type IPolicyEngine, type IPrincipal, type IProviderAuthenticator, type IProviderPrincipal, type IProviderPublishDecision, type IRemoteUpstreamConfig, type IReplayResult, type IResolvedAuth, type IResourceSubscriptionLimits, type IResourceSubscriptionUpstream, type IRoleDefinition, type ISlotResourceResolver, type IStartBrokerServerOptions, type IStaticMount, type IStdioUpstreamConfig, type ISubjectMapper, type ISubjectMappingConfig, type IUpstream, type IWsTunnelOptions, type InternalClient, type JwtAuthOptions, JwtSubjectMapper, JwtTokenValidator, type JwtValidatorOptions, type LoadedBrokerConfig, type McpOperation, type McpbBundleConfig, PACKAGE_NAME, type PolicyAssignment, type PolicyAuthorization, type PolicyEngine, type Principal, type ProtectedResourceMetadata, type ProviderAuthenticationResult, type ProviderAuthenticator, type ProviderAuthenticatorReturn, type ProviderPrincipal, type ProviderPublishDenialReason, type ProviderTakeoverMode, RemoteUpstream, type RemoteUpstreamConfig, type ResolvedAuth, ResourcePath, ResourcePathPattern, ResourceSubscriptionRegistry, type RoleDefinition, SUBSCRIPTION_LIMIT_ERROR_CODE, SharedSecretProviderAuthenticator, type SlotResourceResolver, type StartBrokerServerOptions, type StaticMount, StdioUpstream, type StdioUpstreamConfig, type SubjectMapper, type SubjectMappingConfig, SubjectMappingError, type SubscriptionOutcome, type SubscriptionState, type TokenValidator, type Upstream, VERSION, WsTunnel, WsTunnelBuilder, type WsTunnelOptions, 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 };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { AuthError, BROKER_AGGREGATE_NAME, BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, BROKER_GUIDE_URI_PREFIX, BROKER_GUIDE_URI_TEMPLATE, BROKER_PROVIDER_NAME, BROKER_RESERVED_SLOTS, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, BrokerGuideAdapter, BrokerGuideBehavior, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, DefaultSlotResourceResolver, HttpAuthGuard, JwtSubjectMapper, JwtTokenValidator, PACKAGE_NAME, RemoteUpstream, ResourcePath, ResourcePathPattern, SharedSecretProviderAuthenticator, StdioUpstream, SubjectMappingError, VERSION, WsTunnel, WsTunnelBuilder, authorizationWithEngine, brokerGrammarKey, brokerGuide, brokerGuideIndex, brokerGuideTopicFromUri, brokerGuideUri, buildJwtAuth, buildResourceMetadata, compileAuthorizationPolicy, compileProviderAllowedResources, diagnoseBroker, hasAuthorizationPolicies, isReservedBrokerSlot, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerConfig, loadBrokerGrammar, loadMcpbBundle, normalizeProviderAuthentication, providerMayPublish, providerPublishDecision, resolveOpenTarget, scopesOf, startBrokerServer, unzipMcpb, validateCapability } from './chunk-J5TN5RYU.js';
1
+ export { AuthError, BROKER_AGGREGATE_NAME, BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, BROKER_GUIDE_URI_PREFIX, BROKER_GUIDE_URI_TEMPLATE, BROKER_PROVIDER_NAME, BROKER_RESERVED_SLOTS, BrokerDiagnoseAdapter, BrokerDiagnoseBehavior, BrokerGuideAdapter, BrokerGuideBehavior, BrokerInfoBehavior, BrokerProvidersBehavior, ConfigPolicyEngine, ConfiguredCapabilityClassifier, DEFAULT_CONFIG_FILENAME, 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 } from './chunk-YTRVLPHP.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanmycelium/mcp-broker",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "WebSocket-based Model Context Protocol broker. Aggregates multiple MCP providers behind a single endpoint with stdio, SSE, and Streamable HTTP client transports.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -69,7 +69,7 @@
69
69
  "prepublishOnly": "npm run lint && npm run build && npm test"
70
70
  },
71
71
  "dependencies": {
72
- "@cyanmycelium/mcp-core": "^1.1.0",
72
+ "@cyanmycelium/mcp-core": "^1.3.0",
73
73
  "jose": "^6.2.3",
74
74
  "open": "^11.0.0",
75
75
  "ws": "^8.18.0"
@@ -22,6 +22,11 @@ interface IProviderMapping {
22
22
  const STATIC_CAPABILITIES: Readonly<Record<string, string>> = {
23
23
  "resources/list": "mcp.resources.read",
24
24
  "resources/read": "mcp.resources.read",
25
+ "resources/templates/list": "mcp.resources.read",
26
+ // Subscribing is reading over time. Unsubscribing is deliberately absent:
27
+ // an unclassified method is not refused, and a client whose grant was
28
+ // revoked must still be able to drop what it holds.
29
+ "resources/subscribe": "mcp.resources.read",
25
30
  "tools/list": "mcp.tools.list",
26
31
  "prompts/list": "mcp.prompts.read",
27
32
  "prompts/get": "mcp.prompts.read",
@@ -34,6 +39,14 @@ const STATIC_CAPABILITIES: Readonly<Record<string, string>> = {
34
39
 
35
40
  const BROKER_READ_TOOLS = new Set(["broker_info", "providers_list", "provider_status"]);
36
41
 
42
+ /**
43
+ * Methods that only read broker state. On `_broker` they all need
44
+ * `broker.providers.read`, the capability its tools need, rather than the
45
+ * generic `mcp.resources.read`: otherwise a caller could read the list and
46
+ * not subscribe to it, or the reverse.
47
+ */
48
+ const BROKER_READ_METHODS = new Set(["tools/list", "resources/list", "resources/read", "resources/templates/list", "resources/subscribe", "notifications/resources/updated"]);
49
+
37
50
  function toolNameFrom(params: unknown): string | undefined {
38
51
  if (typeof params !== "object" || params === null || Array.isArray(params)) return undefined;
39
52
  const name = (params as Readonly<Record<string, unknown>>)["name"];
@@ -80,7 +93,7 @@ export class ConfiguredCapabilityClassifier implements ICapabilityClassifier {
80
93
  if (!method) return undefined;
81
94
 
82
95
  if (provider === "_broker") {
83
- if (method === "tools/list" || method === "resources/list" || method === "resources/read") {
96
+ if (BROKER_READ_METHODS.has(method)) {
84
97
  return { capability: "broker.providers.read" };
85
98
  }
86
99
  if (method === "tools/call") {
package/src/bin.ts CHANGED
@@ -176,6 +176,9 @@ envFromConfig("MCP_BROKER_SSE_PATH", config.paths?.sse);
176
176
  envFromConfig("MCP_BROKER_MESSAGES_PATH", config.paths?.messages);
177
177
  envFromConfig("MCP_BROKER_PROVIDER_HEARTBEAT_MS", config.providerHeartbeatIntervalMs);
178
178
  envFromConfig("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS", config.providerRequestTimeoutMs);
179
+ envFromConfig("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT", config.resourceSubscriptions?.maxSubscriptionsPerClient);
180
+ envFromConfig("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT", config.resourceSubscriptions?.maxSubscriptionsPerSlot);
181
+ envFromConfig("MCP_BROKER_MAX_RESOURCE_URI_LENGTH", config.resourceSubscriptions?.maxResourceUriLength);
179
182
  envFromConfig("MCP_BROKER_PROVIDER_TAKEOVER", config.providerTakeover);
180
183
  // `www.open` is `boolean | string`: `true` means the root, a string is a path
181
184
  // or a same-origin URL. Both travel as the env string and are resolved once,
@@ -253,6 +256,24 @@ function millisFromEnv(envName: string): number | undefined {
253
256
  const providerHeartbeatIntervalMs = millisFromEnv("MCP_BROKER_PROVIDER_HEARTBEAT_MS");
254
257
  const providerRequestTimeoutMs = millisFromEnv("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS");
255
258
 
259
+ /** Reads a positive whole number from the environment, or `undefined` (with a warning) for anything else. */
260
+ function countFromEnv(envName: string): number | undefined {
261
+ const raw = process.env[envName];
262
+ if (raw === undefined || raw.trim() === "") return undefined;
263
+ const value = Number(raw);
264
+ if (!Number.isInteger(value) || value < 1) {
265
+ console.warn(`[mcp-broker] Ignoring ${envName}="${raw}": expected a whole number of at least 1. Using the default.`);
266
+ return undefined;
267
+ }
268
+ return value;
269
+ }
270
+
271
+ const resourceSubscriptionLimits = {
272
+ maxSubscriptionsPerClient: countFromEnv("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT"),
273
+ maxSubscriptionsPerSlot: countFromEnv("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT"),
274
+ maxResourceUriLength: countFromEnv("MCP_BROKER_MAX_RESOURCE_URI_LENGTH"),
275
+ };
276
+
256
277
  const takeoverRaw = process.env["MCP_BROKER_PROVIDER_TAKEOVER"]?.trim().toLowerCase();
257
278
  let providerTakeover: ProviderTakeoverMode | undefined;
258
279
  if (takeoverRaw) {
@@ -367,6 +388,11 @@ async function main(): Promise<void> {
367
388
  if (providerRequestTimeoutMs !== undefined) {
368
389
  builder.withProviderRequestTimeout(providerRequestTimeoutMs);
369
390
  }
391
+ // Only the limits actually set: an `undefined` field would override the default.
392
+ const limits = Object.fromEntries(Object.entries(resourceSubscriptionLimits).filter(([, v]) => v !== undefined));
393
+ if (Object.keys(limits).length > 0) {
394
+ builder.withResourceSubscriptionLimits(limits);
395
+ }
370
396
  if (providerTakeover) {
371
397
  builder.withProviderTakeover(providerTakeover);
372
398
  }
@@ -8,6 +8,15 @@ export const PROVIDERS_URI = "broker://providers";
8
8
  /** RFC 6570 URI template for one specific provider slot. */
9
9
  export const PROVIDER_URI_TEMPLATE = "broker://providers/{name}";
10
10
 
11
+ /**
12
+ * The URI of one slot's resource, as the `broker://providers/{name}` template
13
+ * expands it. The name is percent-encoded, so a slot such as `a/b` is
14
+ * `broker://providers/a%2Fb`; a subscription must use that exact string.
15
+ */
16
+ export function providerUri(name: string): string {
17
+ return `broker://providers/${encodeURIComponent(name)}`;
18
+ }
19
+
11
20
  /**
12
21
  * Adapter that exposes the broker's current provider slots, both as a list
13
22
  * (read of `broker://providers`) and individually (`broker://providers/<name>`).
@@ -15,6 +24,15 @@ export const PROVIDER_URI_TEMPLATE = "broker://providers/{name}";
15
24
  export class BrokerProvidersAdapter extends McpAdapterBase {
16
25
  constructor(private readonly _context: IBrokerContext) {
17
26
  super("broker");
27
+
28
+ // Each batch of slot changes updates the list once, and each slot's
29
+ // own resource once. The server delivers them only to a session that
30
+ // subscribed to that exact URI.
31
+ _context.onProvidersChanged?.subscribe((names) => {
32
+ if (names.length === 0) return;
33
+ this._forwardResourceContentChanged(PROVIDERS_URI);
34
+ for (const name of new Set(names)) this._forwardResourceContentChanged(providerUri(name));
35
+ });
18
36
  }
19
37
 
20
38
  public async readResourceAsync(uri: string): Promise<McpResourceContent | undefined> {
@@ -68,6 +68,12 @@ export class AggregateServer implements IMessageTransport {
68
68
  onClose: (() => void) | null = null;
69
69
  onError: ((error: Error) => void) | null = null;
70
70
 
71
+ /**
72
+ * Called with a slot name whenever it joins or leaves the aggregate, so the
73
+ * broker can announce the change on `broker://providers`.
74
+ */
75
+ onMembershipChanged: ((name: string) => void) | null = null;
76
+
71
77
  constructor(openClient: InternalClientFactory) {
72
78
  this._openClient = openClient;
73
79
  }
@@ -139,6 +145,7 @@ export class AggregateServer implements IMessageTransport {
139
145
 
140
146
  const session = new ProviderClientSession(name, this._openClient(name));
141
147
  this._sessions.set(name, session);
148
+ this.onMembershipChanged?.(name);
142
149
 
143
150
  session.onCatalogChanged = (): void => {
144
151
  this._catalog.setProvider(name, { tools: session.tools, prompts: session.prompts });
@@ -181,6 +188,7 @@ export class AggregateServer implements IMessageTransport {
181
188
  session.close();
182
189
  this._catalog.removeProvider(name);
183
190
  this._emitListChanged();
191
+ this.onMembershipChanged?.(name);
184
192
  }
185
193
 
186
194
  private _subjectFor(principal: IPrincipal | null): IAuthorizationSubject {
@@ -1,5 +1,5 @@
1
1
  import { McpBehavior } from "@cyanmycelium/mcp-core";
2
- import type { McpResource, McpTool } from "@cyanmycelium/mcp-core";
2
+ import type { McpResource, McpResourceContent, McpTool } from "@cyanmycelium/mcp-core";
3
3
  import { BROKER_INFO_URI, BrokerInfoAdapter } from "../adapters/broker.adapter.info";
4
4
  import { brokerBaselineResourceDescription, brokerBaselineResourceName, brokerBaselineToolDescription } from "../broker.grammars";
5
5
  import type { IBrokerContext } from "../broker.context";
@@ -19,6 +19,15 @@ export class BrokerInfoBehavior extends McpBehavior {
19
19
  });
20
20
  }
21
21
 
22
+ /**
23
+ * Always reads live. `McpBehavior` caches the content of its root
24
+ * resource on first read and never refreshes it, which suits static
25
+ * content but froze this snapshot at whatever the first reader saw.
26
+ */
27
+ public override readResourceAsync(uri: string): Promise<McpResourceContent | undefined> {
28
+ return this.adapter.readResourceAsync(uri);
29
+ }
30
+
22
31
  protected override _buildResources(): McpResource[] {
23
32
  return [
24
33
  {
@@ -1,5 +1,5 @@
1
1
  import { McpBehavior } from "@cyanmycelium/mcp-core";
2
- import type { McpResource, McpResourceTemplate, McpTool } from "@cyanmycelium/mcp-core";
2
+ import type { McpResource, McpResourceContent, McpResourceTemplate, McpTool } from "@cyanmycelium/mcp-core";
3
3
  import { BrokerProvidersAdapter, PROVIDERS_URI, PROVIDER_URI_TEMPLATE } from "../adapters/broker.adapter.providers";
4
4
  import {
5
5
  brokerBaselinePropertyDescription,
@@ -29,6 +29,15 @@ export class BrokerProvidersBehavior extends McpBehavior {
29
29
  });
30
30
  }
31
31
 
32
+ /**
33
+ * Always reads live. `McpBehavior` caches the content of its root
34
+ * resource on first read and never refreshes it, which suits static
35
+ * content but froze this snapshot at whatever the first reader saw.
36
+ */
37
+ public override readResourceAsync(uri: string): Promise<McpResourceContent | undefined> {
38
+ return this.adapter.readResourceAsync(uri);
39
+ }
40
+
32
41
  protected override _buildResources(): McpResource[] {
33
42
  return [
34
43
  {
@@ -1,3 +1,5 @@
1
+ import type { IEventSource } from "@cyanmycelium/mcp-core";
2
+
1
3
  /**
2
4
  * Read-only view of the broker's runtime state, exposed to broker behaviors.
3
5
  *
@@ -76,6 +78,19 @@ export interface IBrokerContext {
76
78
  * a slot that cannot exist at host start.
77
79
  */
78
80
  getStdioBridgeTarget?(): string | null | undefined;
81
+
82
+ /**
83
+ * Fires with the names of the slots whose *state* changed: a slot
84
+ * appeared, a provider attached or detached, a slot joined or left `_all`.
85
+ * Changes in the same tick arrive as one batch.
86
+ *
87
+ * Counters (`pendingCount`, `clientCount`, `sessionCount`) deliberately do
88
+ * not fire it: reading a resource moves them, so notifying on them would
89
+ * make every read trigger the next notification. They stay readable on
90
+ * demand. `_broker` turns this into `notifications/resources/updated` on
91
+ * `broker://providers` and `broker://providers/<name>`.
92
+ */
93
+ readonly onProvidersChanged?: IEventSource<readonly string[]>;
79
94
  }
80
95
 
81
96
  /**
@@ -159,6 +174,14 @@ export interface IBrokerProviderInfo {
159
174
 
160
175
  /** Number of in-flight JSON-RPC requests awaiting a response. */
161
176
  pendingCount: number;
177
+
178
+ /**
179
+ * Client/URI pairs held by `resources/subscribe` on this slot. A number
180
+ * that only grows is the signature of Streamable HTTP clients leaving
181
+ * without `DELETE`: their sessions, and so their subscriptions, never
182
+ * expire. Optional so hand-written contexts keep compiling.
183
+ */
184
+ resourceSubscriptionCount?: number;
162
185
  }
163
186
 
164
187
  /** @deprecated Use {@link IBrokerContext}. */