@cyanmycelium/mcp-broker 1.3.4 → 1.5.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.
Files changed (49) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +34 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +35 -0
  3. package/.mcp-broker.example/README.md +8 -0
  4. package/.mcp-broker.example/config.json +6 -0
  5. package/.mcp-broker.example/security.example.json +42 -0
  6. package/README.md +169 -1
  7. package/dist/bin.js +150 -22
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-BTWLF6KI.js +378 -0
  10. package/dist/chunk-BTWLF6KI.js.map +1 -0
  11. package/dist/{chunk-J5TN5RYU.js → chunk-K2EQP4RQ.js} +3211 -1138
  12. package/dist/chunk-K2EQP4RQ.js.map +1 -0
  13. package/dist/index.d.ts +342 -1996
  14. package/dist/index.js +2 -1
  15. package/dist/testing/index.d.ts +93 -0
  16. package/dist/testing/index.js +108 -0
  17. package/dist/testing/index.js.map +1 -0
  18. package/dist/ws.tunnel.builder-BYCkbtA7.d.ts +2691 -0
  19. package/package.json +7 -2
  20. package/src/auth/index.ts +2 -1
  21. package/src/auth/provider.auth.ts +91 -0
  22. package/src/authority/broker.authority.ts +641 -0
  23. package/src/authority/declaration.ts +414 -0
  24. package/src/authorization/capability.classifier.ts +14 -1
  25. package/src/authorization/policy.types.ts +47 -1
  26. package/src/authorization/runtime.ts +8 -0
  27. package/src/bin.ts +181 -22
  28. package/src/broker/adapters/broker.adapter.info.ts +3 -0
  29. package/src/broker/adapters/broker.adapter.providers.ts +18 -0
  30. package/src/broker/aggregate/aggregate.server.ts +22 -1
  31. package/src/broker/aggregate/provider.client.session.ts +16 -8
  32. package/src/broker/behaviors/broker.behavior.info.ts +10 -1
  33. package/src/broker/behaviors/broker.behavior.providers.ts +10 -1
  34. package/src/broker/broker.context.ts +35 -0
  35. package/src/broker/broker.diagnostics.ts +103 -1
  36. package/src/broker/broker.guides.ts +149 -2
  37. package/src/config.ts +245 -11
  38. package/src/index.ts +70 -3
  39. package/src/subscriptions/resource.subscription.registry.ts +370 -0
  40. package/src/telemetry/index.ts +15 -0
  41. package/src/telemetry/otlp.http.exporter.ts +117 -0
  42. package/src/telemetry/telemetry.dispatcher.ts +231 -0
  43. package/src/telemetry/telemetry.types.ts +79 -0
  44. package/src/telemetry/trace.context.ts +50 -0
  45. package/src/testing/index.ts +230 -0
  46. package/src/ws/ws.interfaces.ts +95 -3
  47. package/src/ws/ws.tunnel.builder.ts +100 -2
  48. package/src/ws/ws.tunnel.ts +878 -43
  49. package/dist/chunk-J5TN5RYU.js.map +0 -1
@@ -333,6 +333,40 @@ What happens when a provider connects to a slot another socket already holds.
333
333
 
334
334
  Also `MCP_BROKER_PROVIDER_TAKEOVER`.
335
335
 
336
+ ## Resource subscriptions
337
+
338
+ ```json
339
+ "resourceSubscriptions": {
340
+ "maxSubscriptionsPerClient": 64,
341
+ "maxSubscriptionsPerSlot": 1024,
342
+ "maxResourceUriLength": 2048
343
+ }
344
+ ```
345
+
346
+ When a client sends `resources/subscribe`, the broker answers it itself and
347
+ asks the provider only once per URI, however many clients watch it. It then
348
+ delivers `notifications/resources/updated` to the clients that subscribed to
349
+ that URI, and to nobody else. These three optional keys bound what clients can
350
+ make it hold; the values shown are the defaults.
351
+
352
+ ### `maxSubscriptionsPerClient`
353
+
354
+ How many URIs one client (a WebSocket, an SSE stream, a Streamable HTTP session)
355
+ may watch at once. Past it, the subscription is refused with `-32000`. Also
356
+ `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT`.
357
+
358
+ ### `maxSubscriptionsPerSlot`
359
+
360
+ How many client subscriptions one slot may hold in total. This is the bound that
361
+ matters for Streamable HTTP clients that close their tab without `DELETE`:
362
+ their sessions never expire, and neither do their subscriptions. Also
363
+ `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT`.
364
+
365
+ ### `maxResourceUriLength`
366
+
367
+ The longest URI accepted, in characters. A longer one is refused with `-32602`.
368
+ Also `MCP_BROKER_MAX_RESOURCE_URI_LENGTH`.
369
+
336
370
  ## TLS
337
371
 
338
372
  ```json
@@ -340,6 +340,41 @@ autre socket.
340
340
 
341
341
  Aussi `MCP_BROKER_PROVIDER_TAKEOVER`.
342
342
 
343
+ ## Abonnements aux ressources
344
+
345
+ ```json
346
+ "resourceSubscriptions": {
347
+ "maxSubscriptionsPerClient": 64,
348
+ "maxSubscriptionsPerSlot": 1024,
349
+ "maxResourceUriLength": 2048
350
+ }
351
+ ```
352
+
353
+ Quand un client envoie `resources/subscribe`, le broker y répond lui-même et ne
354
+ sollicite le fournisseur qu'une seule fois par URI, quel que soit le nombre de
355
+ clients qui la surveillent. Il remet ensuite `notifications/resources/updated`
356
+ aux seuls clients abonnés à cette URI. Ces trois clés facultatives bornent ce
357
+ que les clients peuvent lui faire conserver ; les valeurs montrées sont celles
358
+ par défaut.
359
+
360
+ ### `maxSubscriptionsPerClient`
361
+
362
+ Nombre d'URI qu'un même client (un WebSocket, un flux SSE, une session
363
+ Streamable HTTP) peut surveiller à la fois. Au-delà, l'abonnement est refusé
364
+ avec `-32000`. Aussi `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT`.
365
+
366
+ ### `maxSubscriptionsPerSlot`
367
+
368
+ Nombre total d'abonnements clients qu'un slot peut conserver. C'est la borne qui
369
+ compte pour les clients Streamable HTTP qui ferment leur onglet sans `DELETE` :
370
+ leurs sessions n'expirent jamais, leurs abonnements non plus. Aussi
371
+ `MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT`.
372
+
373
+ ### `maxResourceUriLength`
374
+
375
+ Longueur maximale d'une URI, en caractères. Une URI plus longue est refusée avec
376
+ `-32602`. Aussi `MCP_BROKER_MAX_RESOURCE_URI_LENGTH`.
377
+
343
378
  ## TLS
344
379
 
345
380
  ```json
@@ -23,6 +23,14 @@ factory deployment. Two consequences.
23
23
  use. A missing directory is skipped with a warning; a missing TLS file stops
24
24
  startup, with a message naming the two files and the three ways out.
25
25
 
26
+ 3. **`security.example.json` is the other way to hold `auth`** (broker 1.5.0):
27
+ a separate security file with one identity and secret per provider and the
28
+ protected slots, apart from the topology. To use it, rename it
29
+ `security.json`, add `"securityFile": "security.json"` to `config.json`, and
30
+ **delete the `auth` block from `config.json`**: the broker refuses to start
31
+ with `auth` in both files. Each provider's secret comes from the environment
32
+ variable its entry names, never from the file.
33
+
26
34
  So a straight `cp -r` does not run yet. Do one of these first:
27
35
 
28
36
  ```sh
@@ -19,6 +19,12 @@
19
19
  "providerRequestTimeoutMs": 60000,
20
20
  "providerTakeover": "liveness",
21
21
 
22
+ "resourceSubscriptions": {
23
+ "maxSubscriptionsPerClient": 64,
24
+ "maxSubscriptionsPerSlot": 1024,
25
+ "maxResourceUriLength": 2048
26
+ },
27
+
22
28
  "tls": {
23
29
  "cert": "certs/cert.pem",
24
30
  "key": "certs/key.pem"
@@ -0,0 +1,42 @@
1
+ {
2
+ "description": "Security settings, kept apart from config.json. Name this file from config.json with \"securityFile\": \"security.json\", or with MCP_BROKER_SECURITY_FILE. No secret is written here: each provider names the environment variable that holds its own.",
3
+ "auth": {
4
+ "enabled": true,
5
+ "publicBaseUrl": "https://broker.example.com",
6
+ "authorizationServers": ["https://login.example.com"],
7
+ "jwks": "https://login.example.com/.well-known/jwks.json",
8
+ "requiredScopes": ["mcp:call"],
9
+ "subjectMapping": { "userClaim": "sub", "groupClaims": ["groups"], "serviceClaims": ["service"] },
10
+ "slotResources": {
11
+ "scada": "/production/site1/scada",
12
+ "bench-motor01": "/production/site1/bench/motor01"
13
+ },
14
+ "roles": {
15
+ "mcp-caller": { "capabilities": ["mcp.tools.list", "mcp.tools.call"] },
16
+ "scada-observer": { "inherits": ["mcp-caller"], "capabilities": ["scada.observe"] },
17
+ "scada-operator": { "inherits": ["scada-observer"], "capabilities": ["scada.acquire", "scada.control"] }
18
+ },
19
+ "assignments": [
20
+ { "id": "line1-operators", "subject": "group:operators-line1", "role": "scada-operator", "resource": "/production/site1/line1/**" },
21
+ { "id": "scada-service", "subject": "service:mcp-scada", "role": "mcp-caller", "resource": "/production/site1/**" }
22
+ ]
23
+ },
24
+ "providers": [
25
+ {
26
+ "id": "mcp-scada",
27
+ "secretEnv": "SCADA_PROVIDER_SECRET",
28
+ "subjects": ["service:mcp-scada"],
29
+ "allowedResources": ["/production/site1/**"]
30
+ },
31
+ {
32
+ "id": "modbus-bench",
33
+ "secretEnv": "MODBUS_PROVIDER_SECRET",
34
+ "allowedResources": ["/production/site1/bench/**"]
35
+ }
36
+ ],
37
+ "authorization": {
38
+ "protectedSlots": {
39
+ "bench-motor01": { "declaredBy": "mcp-scada", "publishedBy": "modbus-bench" }
40
+ }
41
+ }
42
+ }
package/README.md CHANGED
@@ -79,6 +79,10 @@ the observable signatures are worth knowing:
79
79
 
80
80
  `broker_diagnose` reports the first as `transport-path-mismatch`.
81
81
 
82
+ > **Testing?** `startTestBroker()` from `@cyanmycelium/mcp-broker/testing` runs a
83
+ > real broker in one call, with callers that need no authorization server. See
84
+ > [docs/testing.md](docs/testing.md).
85
+
82
86
  ## Configuration
83
87
 
84
88
  Two sources, env vars **always win** over the file. The file is the static baseline you ship with the broker; env vars are deploy-specific overrides.
@@ -159,6 +163,8 @@ This table is complete: it lists every `MCP_BROKER_*` variable the CLI reads.
159
163
  | `MCP_BROKER_PROVIDER_HEARTBEAT_MS` | `30000` | ws-level ping interval on provider sockets. `0` disables |
160
164
  | `MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS` | `60000` | How long a provider has to answer one request before it is failed. `0` disables |
161
165
  | `MCP_BROKER_PROVIDER_TAKEOVER` | `liveness` | `reject`, `liveness` or `always` when a second provider claims an occupied slot |
166
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | (unset) | Standard OpenTelemetry full traces endpoint. Enables provider telemetry export |
167
+ | `OTEL_EXPORTER_OTLP_HEADERS` | (unset) | Standard comma-separated, percent-encoded OTLP headers |
162
168
  | `MCP_BROKER_WWW_DIR` | (unset) | If set, serve this directory at `/` |
163
169
  | `MCP_BROKER_BUNDLE_DIR` | (unset) | If set, serve this directory at `/bundle` |
164
170
  | `MCP_BROKER_OPEN` | (unset) | `1` opens the broker root on startup; a `/path` or a same-origin absolute URL opens that page. Opens only when a static mount actually covers the resolved path |
@@ -225,6 +231,19 @@ Matching resources for clients that prefer `resources/read`: `broker://info`,
225
231
  `broker://providers`, the template `broker://providers/{name}`, the six
226
232
  `broker://guide/<topic>` pages and the template `broker://guide/{topic}`.
227
233
 
234
+ **Watch slots instead of polling.** `broker://providers` and
235
+ `broker://providers/<name>` accept `resources/subscribe`. A slot appearing, a
236
+ provider attaching or detaching, a slot joining or leaving `_all` each send
237
+ `notifications/resources/updated`; the counters never do, since reading moves
238
+ them. Reads are always live.
239
+
240
+ ```jsonc
241
+ // -> {"jsonrpc":"2.0","id":2,"method":"resources/subscribe","params":{"uri":"broker://providers"}}
242
+ // <- {"jsonrpc":"2.0","id":2,"result":{}}
243
+ // ...a provider connects:
244
+ // <- {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"broker://providers"}}
245
+ ```
246
+
228
247
  Each provider entry from `providers_list` / `provider_status`:
229
248
 
230
249
  ```json
@@ -237,7 +256,8 @@ Each provider entry from `providers_list` / `provider_status`:
237
256
  "connectedForMs": 184211,
238
257
  "clientCount": 0,
239
258
  "sessionCount": 1,
240
- "pendingCount": 0
259
+ "pendingCount": 0,
260
+ "resourceSubscriptionCount": 0
241
261
  }
242
262
  ```
243
263
 
@@ -256,6 +276,9 @@ Each provider entry from `providers_list` / `provider_status`:
256
276
  Streamable HTTP client reads as `clientCount: 0, sessionCount: 1`.
257
277
  - `pendingCount` is the number of in-flight requests. One that only grows is the
258
278
  signature of a provider that is connected and not answering.
279
+ - `resourceSubscriptionCount` is the client/URI pairs held by
280
+ `resources/subscribe`. One that only grows is HTTP clients leaving without
281
+ `DELETE`.
259
282
 
260
283
  ### `_all`, the aggregate
261
284
 
@@ -306,6 +329,148 @@ rejecting the call: a caller sees only the providers it is scoped for, and a
306
329
  tool it may not see answers `-32602 Unknown aggregated tool`, deliberately
307
330
  indistinguishable from a name that does not exist.
308
331
 
332
+ ## Resource subscriptions
333
+
334
+ `resources/subscribe` works on every slot except `_all`, over every client
335
+ transport. The broker owns the reference count between clients and provider:
336
+
337
+ - N clients on one URI cost the provider **one** `resources/subscribe`; the last
338
+ one leaving sends **one** `resources/unsubscribe`. Concurrent requests are
339
+ serialized per URI.
340
+ - `notifications/resources/updated` reaches only the sessions subscribed to that
341
+ exact `params.uri`. One without a usable `uri` is dropped and logged, never
342
+ broadcast. Every other notification is still broadcast to the slot.
343
+ - Closing a WebSocket or an SSE stream, `DELETE /<slot>/mcp`, closing stdio,
344
+ closing an in-process client from `openInternalClient()`, and stopping the
345
+ broker all release what the client held.
346
+ - When a provider reconnects, the broker replays the last client `initialize`,
347
+ then one subscribe per URI still held, then sends each subscriber one
348
+ `updated` so it re-reads. Install the provider's message handler before its
349
+ socket opens.
350
+ - `resources/subscribe` needs `mcp.resources.read` on the slot
351
+ (`broker.providers.read` on `_broker`); each update is re-checked per
352
+ recipient, and one that lost the grant is unsubscribed. `resources/unsubscribe`
353
+ is never refused.
354
+ - Limits: `resourceSubscriptions` in `config.json` (see
355
+ [config.md](docs/config.md#resourcesubscriptions)), or
356
+ `withResourceSubscriptionLimits()` on the builder.
357
+
358
+ A provider built on `@cyanmycelium/mcp-core` 1.3.0 or later answers
359
+ `resources/subscribe` itself; its behaviors only report changes:
360
+
361
+ ```ts
362
+ class GaugeAdapter extends McpAdapterBase {
363
+ set(value: number): void {
364
+ this._value = value;
365
+ this._forwardResourceContentChanged("plant://gauge"); // -> notifications/resources/updated, subscribers only
366
+ }
367
+ }
368
+ ```
369
+
370
+ ## Provider telemetry and OTLP
371
+
372
+ The complete protocol and deployment contract is in
373
+ [docs/telemetry.md](https://github.com/pandaGaume/mcp-broker/blob/main/docs/telemetry.md).
374
+
375
+ The optional telemetry extension accepts `broker/telemetry` from any
376
+ provider slot. The broker consumes these notifications itself. It never sends
377
+ them to MCP clients, and it never waits for the exporter while routing requests
378
+ or responses.
379
+
380
+ The pipeline has explicit bounds. By default it accepts frames up to 64 KiB,
381
+ queues at most 256 spans, exports batches of up to 32, and drops new telemetry
382
+ when the queue is full. `getTelemetryStats()` reports every accepted, exported,
383
+ and dropped span. A trace failure therefore cannot block MCP control traffic.
384
+
385
+ For the CLI, set the standard OpenTelemetry environment variable:
386
+
387
+ ```sh
388
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4318/v1/traces mcp-broker
389
+ ```
390
+
391
+ `OTEL_EXPORTER_OTLP_HEADERS` carries optional comma-separated, percent-encoded
392
+ headers. The same settings can be kept in `config.json` under `telemetry`:
393
+
394
+ ```json
395
+ {
396
+ "telemetry": {
397
+ "otlpHttpEndpoint": "http://otel-collector:4318/v1/traces",
398
+ "timeoutMs": 5000,
399
+ "queueCapacity": 256,
400
+ "batchSize": 32
401
+ }
402
+ }
403
+ ```
404
+
405
+ Configure the built-in dependency-free OTLP/HTTP JSON exporter:
406
+
407
+ ```ts
408
+ import { WsTunnelBuilder } from "@cyanmycelium/mcp-broker";
409
+
410
+ const broker = new WsTunnelBuilder()
411
+ .withPort(3000)
412
+ .withOtlpHttpTelemetry(
413
+ {
414
+ endpoint: "http://otel-collector:4318/v1/traces",
415
+ headers: { Authorization: `Bearer ${process.env.OTLP_TOKEN}` },
416
+ },
417
+ {
418
+ queueCapacity: 512,
419
+ batchSize: 32,
420
+ onExportError: (error) => console.error("telemetry export failed", error),
421
+ }
422
+ )
423
+ .build();
424
+ ```
425
+
426
+ Or pass any sink with `withTelemetry({ exporter })`. This is useful for a file,
427
+ Kafka, an existing OpenTelemetry SDK, or a deterministic test collector.
428
+
429
+ Provider wire format:
430
+
431
+ ```json
432
+ {
433
+ "jsonrpc": "2.0",
434
+ "method": "broker/telemetry",
435
+ "params": {
436
+ "version": 1,
437
+ "signal": "traces",
438
+ "span": {
439
+ "traceId": "0123456789abcdef0123456789abcdef",
440
+ "spanId": "0123456789abcdef",
441
+ "parentSpanId": "fedcba9876543210",
442
+ "name": "modbus.read",
443
+ "kind": 3,
444
+ "startTimeUnixNano": "1720000000000000000",
445
+ "endTimeUnixNano": "1720000000001000000",
446
+ "attributes": {
447
+ "modbus.function_code": 3,
448
+ "network.transport": "tcp"
449
+ },
450
+ "events": [
451
+ {
452
+ "name": "pdu.rx",
453
+ "timeUnixNano": "1720000000000900000",
454
+ "attributes": { "bytes": 9 }
455
+ }
456
+ ],
457
+ "status": { "code": 1 }
458
+ }
459
+ }
460
+ }
461
+ ```
462
+
463
+ Trace IDs and parent relationships follow W3C Trace Context representation.
464
+ Every routed request carries the context in `params._meta.traceparent`.
465
+ TypeScript providers can continue it with `traceparentOf()`,
466
+ `childTraceparent()` and `withTraceparent()`, then emit with
467
+ `transport.broker.span(span)`. The exporter produces an OTLP
468
+ `ExportTraceServiceRequest`. `service.name` is the authenticated provider
469
+ principal when available, otherwise the slot. `mcp.provider.slot` always keeps
470
+ the slot, and `mcp.provider.principal` records the authenticated identity. Raw
471
+ protocol payloads are not required by the schema. A provider should emit them
472
+ only when its own runtime trace policy explicitly enables that level of detail.
473
+
309
474
  ## Authorization (OAuth 2.1)
310
475
 
311
476
  By default the broker performs **no** authentication. That is fine behind a
@@ -709,6 +874,9 @@ covers the failures the broker cannot see from the inside.
709
874
  | `-32602 Unknown aggregated tool` | the prefixed name was reconstructed rather than echoed | re-run `tools/list`, pass the name back verbatim |
710
875
  | `did not respond within 60000ms` | the provider stayed connected and never answered | raise `providerRequestTimeoutMs`, or fix the provider |
711
876
  | `sessionCount` grows and never falls | Streamable HTTP and SSE sessions do not expire | send `DELETE /<slot>/mcp` when a client is done; restart if it is already large |
877
+ | Subscribed, `notifications/resources/updated` never arrives | the update names another URI (exact match), carries no `params.uri` (dropped, logged once), or the read grant was revoked | compare URIs byte for byte; read the broker log |
878
+ | `resources/subscribe` answers `-32601` on a provider slot | the provider does not implement it (mcp-core before 1.3.0 did not) | upgrade the provider |
879
+ | `-32000 Subscription limit reached` | `maxSubscriptionsPerClient` or `maxSubscriptionsPerSlot` | unsubscribe what you no longer watch, or raise `resourceSubscriptions` |
712
880
 
713
881
  Reading the console: the broker prints one line per accepted WebSocket upgrade
714
882
  naming the path, the role the router assigned (`dedicated-provider`,
package/dist/bin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { PACKAGE_NAME, VERSION, loadBrokerConfig, WsTunnelBuilder, loadMcpbBundle, BROKER_AGGREGATE_NAME, BROKER_PROVIDER_NAME, resolveOpenTarget } from './chunk-J5TN5RYU.js';
2
+ import { loadBrokerConfig, loadSecurityConfig, BrokerConfigError, loadMcpbBundle, resolveOpenTarget } from './chunk-BTWLF6KI.js';
3
+ import { PACKAGE_NAME, VERSION, WsTunnelBuilder, BROKER_AGGREGATE_NAME, BROKER_PROVIDER_NAME } from './chunk-K2EQP4RQ.js';
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
5
6
  import open from 'open';
@@ -59,6 +60,10 @@ PROVIDER LIVENESS
59
60
  MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS Deadline for one provider answer (default 60000, 0 off)
60
61
  MCP_BROKER_PROVIDER_TAKEOVER reject | liveness | always (default liveness)
61
62
 
63
+ PROVIDER TELEMETRY (OpenTelemetry, opt-in)
64
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT Full OTLP/HTTP endpoint, enables trace export
65
+ OTEL_EXPORTER_OTLP_HEADERS Comma-separated percent-encoded key=value headers
66
+
62
67
  AUTHORIZATION (OAuth 2.1, opt-in)
63
68
  MCP_BROKER_AUTH_ENABLED "1" to require bearer tokens on client endpoints
64
69
  MCP_BROKER_PUBLIC_BASE_URL Public origin, e.g. https://mcp.example.com
@@ -87,8 +92,20 @@ Full reference: https://github.com/pandaGaume/mcp-broker/tree/main/node
87
92
  `
88
93
  );
89
94
  }
90
- var { config, baseDir } = loadBrokerConfig();
95
+ function loadConfigOrExit() {
96
+ try {
97
+ const loaded = loadBrokerConfig();
98
+ return { ...loaded, security: loadSecurityConfig(loaded) };
99
+ } catch (error) {
100
+ if (!(error instanceof BrokerConfigError)) throw error;
101
+ process.stderr.write(`${error.message}
102
+ `);
103
+ process.exit(1);
104
+ }
105
+ }
106
+ var { config, baseDir, security } = loadConfigOrExit();
91
107
  var cwd = process.cwd();
108
+ var authConfig = security?.security.auth ?? config.auth;
92
109
  function envFromConfig(envName, configValue) {
93
110
  if (configValue === void 0 || configValue === null) return;
94
111
  if (process.env[envName] !== void 0 && process.env[envName] !== "") return;
@@ -107,13 +124,16 @@ envFromConfig("MCP_BROKER_SSE_PATH", config.paths?.sse);
107
124
  envFromConfig("MCP_BROKER_MESSAGES_PATH", config.paths?.messages);
108
125
  envFromConfig("MCP_BROKER_PROVIDER_HEARTBEAT_MS", config.providerHeartbeatIntervalMs);
109
126
  envFromConfig("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS", config.providerRequestTimeoutMs);
127
+ envFromConfig("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT", config.resourceSubscriptions?.maxSubscriptionsPerClient);
128
+ envFromConfig("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT", config.resourceSubscriptions?.maxSubscriptionsPerSlot);
129
+ envFromConfig("MCP_BROKER_MAX_RESOURCE_URI_LENGTH", config.resourceSubscriptions?.maxResourceUriLength);
110
130
  envFromConfig("MCP_BROKER_PROVIDER_TAKEOVER", config.providerTakeover);
111
131
  envFromConfig("MCP_BROKER_OPEN", config.www?.open === true ? "1" : typeof config.www?.open === "string" ? config.www.open : void 0);
112
- envFromConfig("MCP_BROKER_AUTH_ENABLED", config.auth?.enabled === true ? "1" : void 0);
113
- envFromConfig("MCP_BROKER_PUBLIC_BASE_URL", config.auth?.publicBaseUrl);
114
- envFromConfig("MCP_BROKER_JWKS", config.auth?.jwks);
115
- envFromConfig("MCP_BROKER_ISSUER", config.auth?.issuer);
116
- envFromConfig("MCP_BROKER_PROVIDER_SECRET", config.auth?.providerSecret);
132
+ envFromConfig("MCP_BROKER_AUTH_ENABLED", authConfig?.enabled === true ? "1" : void 0);
133
+ envFromConfig("MCP_BROKER_PUBLIC_BASE_URL", authConfig?.publicBaseUrl);
134
+ envFromConfig("MCP_BROKER_JWKS", authConfig?.jwks);
135
+ envFromConfig("MCP_BROKER_ISSUER", authConfig?.issuer);
136
+ envFromConfig("MCP_BROKER_PROVIDER_SECRET", authConfig?.providerSecret);
117
137
  var stdioProvider = process.env["MCP_BROKER_STDIO_PROVIDER"];
118
138
  if (stdioProvider) {
119
139
  const toStderr = (...args) => process.stderr.write(args.join(" ") + "\n");
@@ -130,6 +150,27 @@ var clientPath = process.env["MCP_BROKER_CLIENT_PATH"] ?? "/";
130
150
  var mcpPath = process.env["MCP_BROKER_MCP_PATH"] ?? "/mcp";
131
151
  var ssePath = process.env["MCP_BROKER_SSE_PATH"] ?? "/sse";
132
152
  var messagesPath = process.env["MCP_BROKER_MESSAGES_PATH"] ?? "/messages";
153
+ var otlpTracesEndpoint = process.env["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] ?? config.telemetry?.otlpHttpEndpoint;
154
+ function otlpHeadersFromEnv(raw) {
155
+ const headers = {};
156
+ if (!raw) return headers;
157
+ for (const field of raw.split(",")) {
158
+ const separator = field.indexOf("=");
159
+ if (separator <= 0) {
160
+ console.warn(`[mcp-broker] Ignoring malformed OTEL_EXPORTER_OTLP_HEADERS field: ${JSON.stringify(field)}.`);
161
+ continue;
162
+ }
163
+ try {
164
+ const key = decodeURIComponent(field.slice(0, separator).trim());
165
+ const value = decodeURIComponent(field.slice(separator + 1).trim());
166
+ if (key) headers[key] = value;
167
+ } catch {
168
+ console.warn(`[mcp-broker] Ignoring malformed percent encoding in OTEL_EXPORTER_OTLP_HEADERS field: ${JSON.stringify(field)}.`);
169
+ }
170
+ }
171
+ return headers;
172
+ }
173
+ var otlpHeaders = { ...config.telemetry?.headers ?? {}, ...otlpHeadersFromEnv(process.env["OTEL_EXPORTER_OTLP_HEADERS"]) };
133
174
  var tlsCertPath = process.env["MCP_BROKER_TLS_CERT"] ? path.resolve(cwd, process.env["MCP_BROKER_TLS_CERT"]) : config.tls?.cert ? path.resolve(baseDir, config.tls.cert) : null;
134
175
  var tlsKeyPath = process.env["MCP_BROKER_TLS_KEY"] ? path.resolve(cwd, process.env["MCP_BROKER_TLS_KEY"]) : config.tls?.key ? path.resolve(baseDir, config.tls.key) : null;
135
176
  var protocolOverride = process.env["MCP_BROKER_PROTOCOL"]?.toLowerCase();
@@ -154,6 +195,21 @@ function millisFromEnv(envName) {
154
195
  }
155
196
  var providerHeartbeatIntervalMs = millisFromEnv("MCP_BROKER_PROVIDER_HEARTBEAT_MS");
156
197
  var providerRequestTimeoutMs = millisFromEnv("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS");
198
+ function countFromEnv(envName) {
199
+ const raw = process.env[envName];
200
+ if (raw === void 0 || raw.trim() === "") return void 0;
201
+ const value = Number(raw);
202
+ if (!Number.isInteger(value) || value < 1) {
203
+ console.warn(`[mcp-broker] Ignoring ${envName}="${raw}": expected a whole number of at least 1. Using the default.`);
204
+ return void 0;
205
+ }
206
+ return value;
207
+ }
208
+ var resourceSubscriptionLimits = {
209
+ maxSubscriptionsPerClient: countFromEnv("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_CLIENT"),
210
+ maxSubscriptionsPerSlot: countFromEnv("MCP_BROKER_MAX_SUBSCRIPTIONS_PER_SLOT"),
211
+ maxResourceUriLength: countFromEnv("MCP_BROKER_MAX_RESOURCE_URI_LENGTH")
212
+ };
157
213
  var takeoverRaw = process.env["MCP_BROKER_PROVIDER_TAKEOVER"]?.trim().toLowerCase();
158
214
  var providerTakeover;
159
215
  if (takeoverRaw) {
@@ -197,6 +253,34 @@ function warnIfStdioTargetUnknown(target) {
197
253
  `[mcp-broker] MCP_BROKER_STDIO_PROVIDER="${target}" names a slot this broker does not host. Nothing answers on it until a WebSocket provider connects to ${providerPath}/${target} (or announces "${target}" on ${providersPath}), and until then every request from the MCP host, including the initial handshake, fails with 'Provider "${target}" not connected'. An MCP host that starts before that provider is up, which is always the case for a provider hosted in a browser page, will therefore never connect. Prefer MCP_BROKER_STDIO_PROVIDER="${BROKER_AGGREGATE_NAME}": it exists from startup, answers the handshake itself, unions every opted-in provider, and pushes notifications/tools/list_changed as providers join, so a page opened later appears live. Slots this broker hosts right now: ${hosted.length > 0 ? hosted.map((n) => `"${n}"`).join(", ") + `, plus the reserved "${BROKER_AGGREGATE_NAME}" and "${BROKER_PROVIDER_NAME}"` : `the reserved "${BROKER_AGGREGATE_NAME}" and "${BROKER_PROVIDER_NAME}" only`}.`
198
254
  );
199
255
  }
256
+ function telemetryExportLogger(intervalMs = 6e4) {
257
+ let failures = 0;
258
+ let suppressed = 0;
259
+ let lastReport = 0;
260
+ return {
261
+ error(error) {
262
+ failures++;
263
+ const now = Date.now();
264
+ if (lastReport === 0 || now - lastReport >= intervalMs) {
265
+ const suffix = suppressed > 0 ? ` (${suppressed} repeated failure(s) suppressed)` : "";
266
+ console.error(`[mcp-broker] telemetry export failed: ${error.message}${suffix}`);
267
+ lastReport = now;
268
+ suppressed = 0;
269
+ } else {
270
+ suppressed++;
271
+ }
272
+ },
273
+ success(recordCount) {
274
+ if (failures === 0) return;
275
+ console.info(
276
+ `[mcp-broker] telemetry export recovered after ${failures} failed batch(es)${suppressed > 0 ? `, ${suppressed} repeated log line(s) suppressed` : ""}; exported ${recordCount} span(s).`
277
+ );
278
+ failures = 0;
279
+ suppressed = 0;
280
+ lastReport = 0;
281
+ }
282
+ };
283
+ }
200
284
  async function main() {
201
285
  const localGrammarsDir = path.join(baseDir, "grammars");
202
286
  const hasLocalGrammars = fs.existsSync(localGrammarsDir);
@@ -210,9 +294,33 @@ async function main() {
210
294
  if (providerRequestTimeoutMs !== void 0) {
211
295
  builder.withProviderRequestTimeout(providerRequestTimeoutMs);
212
296
  }
297
+ const limits = Object.fromEntries(Object.entries(resourceSubscriptionLimits).filter(([, v]) => v !== void 0));
298
+ if (Object.keys(limits).length > 0) {
299
+ builder.withResourceSubscriptionLimits(limits);
300
+ }
213
301
  if (providerTakeover) {
214
302
  builder.withProviderTakeover(providerTakeover);
215
303
  }
304
+ if (otlpTracesEndpoint) {
305
+ const exportLog = telemetryExportLogger();
306
+ builder.withOtlpHttpTelemetry(
307
+ {
308
+ endpoint: otlpTracesEndpoint,
309
+ headers: otlpHeaders,
310
+ timeoutMs: config.telemetry?.timeoutMs,
311
+ serviceNamespace: config.telemetry?.serviceNamespace
312
+ },
313
+ {
314
+ maxFrameBytes: config.telemetry?.maxFrameBytes,
315
+ queueCapacity: config.telemetry?.queueCapacity,
316
+ batchSize: config.telemetry?.batchSize,
317
+ maxAttributes: config.telemetry?.maxAttributes,
318
+ maxEvents: config.telemetry?.maxEvents,
319
+ onExportError: exportLog.error,
320
+ onExportSuccess: exportLog.success
321
+ }
322
+ );
323
+ }
216
324
  if (useTls) {
217
325
  try {
218
326
  builder.withTlsFiles(tlsCertPath, tlsKeyPath);
@@ -284,7 +392,7 @@ async function main() {
284
392
  const publicBaseUrl = process.env["MCP_BROKER_PUBLIC_BASE_URL"];
285
393
  const jwks = process.env["MCP_BROKER_JWKS"];
286
394
  const issuer = process.env["MCP_BROKER_ISSUER"];
287
- const authorizationServers = config.auth?.authorizationServers ?? (issuer ? [issuer] : []);
395
+ const authorizationServers = authConfig?.authorizationServers ?? (issuer ? [issuer] : []);
288
396
  if (!publicBaseUrl || !jwks || authorizationServers.length === 0) {
289
397
  console.error(
290
398
  "[mcp-broker] auth.enabled requires publicBaseUrl, jwks, and at least one authorizationServers entry (or issuer). Set them via config.auth or MCP_BROKER_PUBLIC_BASE_URL / MCP_BROKER_JWKS / MCP_BROKER_ISSUER."
@@ -296,25 +404,37 @@ async function main() {
296
404
  authorizationServers,
297
405
  jwksUri: jwks,
298
406
  issuer,
299
- scopesSupported: config.auth?.scopesSupported,
300
- requiredScopes: config.auth?.requiredScopes,
301
- perSlotScopes: config.auth?.perSlotScopes,
302
- providerScopes: config.auth?.providerScopes,
303
- subjectMapping: config.auth?.subjectMapping,
304
- roles: config.auth?.roles,
305
- assignments: config.auth?.assignments,
306
- denies: config.auth?.denies,
307
- slotResources: config.auth?.slotResources,
308
- toolCapabilities: config.auth?.toolCapabilities,
309
- providerToolCapabilities: config.auth?.providerToolCapabilities,
310
- audit: config.auth?.audit
407
+ scopesSupported: authConfig?.scopesSupported,
408
+ requiredScopes: authConfig?.requiredScopes,
409
+ perSlotScopes: authConfig?.perSlotScopes,
410
+ providerScopes: authConfig?.providerScopes,
411
+ subjectMapping: authConfig?.subjectMapping,
412
+ roles: authConfig?.roles,
413
+ assignments: authConfig?.assignments,
414
+ denies: authConfig?.denies,
415
+ slotResources: authConfig?.slotResources,
416
+ toolCapabilities: authConfig?.toolCapabilities,
417
+ providerToolCapabilities: authConfig?.providerToolCapabilities,
418
+ audit: authConfig?.audit
311
419
  });
312
420
  }
313
421
  const providerSecret = process.env["MCP_BROKER_PROVIDER_SECRET"];
314
422
  if (providerSecret) {
315
423
  builder.withProviderSecret(providerSecret);
316
424
  }
317
- const tunnel = builder.build();
425
+ let tunnel;
426
+ try {
427
+ if (security) {
428
+ if (security.credentials.length > 0) builder.withProviderPrincipals(security.credentials);
429
+ const protectedSlots = security.security.authorization?.protectedSlots;
430
+ if (protectedSlots && Object.keys(protectedSlots).length > 0) builder.withProtectedSlots(protectedSlots);
431
+ builder.withSecurityVersion(security.version);
432
+ }
433
+ tunnel = builder.build();
434
+ } catch (error) {
435
+ console.error(`[mcp-broker] ${error.message}`);
436
+ process.exit(1);
437
+ }
318
438
  await tunnel.start();
319
439
  const httpScheme = useTls ? "https" : "http";
320
440
  const wsScheme = useTls ? "wss" : "ws";
@@ -343,12 +463,20 @@ async function main() {
343
463
  console.log(`\u{1F310} Local grammars ${localGrammarsDir}`);
344
464
  }
345
465
  console.log(`\u{1F510} Authorization ${authEnabled ? "OAuth 2.1 (Bearer required)" : "disabled (trusted network only)"}`);
346
- console.log(`\u{1F6E1}\uFE0F Provider auth ${providerSecret ? "shared secret required" : "disabled"}`);
466
+ const providerIdentities = security?.credentials.length ?? 0;
467
+ console.log(
468
+ `\u{1F6E1}\uFE0F Provider auth ${providerIdentities > 0 ? `${providerIdentities} provider identit${providerIdentities === 1 ? "y" : "ies"}${providerSecret ? " + shared secret" : ""}` : providerSecret ? "shared secret required" : "disabled"}`
469
+ );
470
+ if (security) {
471
+ const protectedCount = Object.keys(security.security.authorization?.protectedSlots ?? {}).length;
472
+ console.log(`\u{1F50F} Security file ${security.sourcePath} (version ${security.version}${protectedCount > 0 ? `, ${protectedCount} protected slot(s)` : ""})`);
473
+ }
347
474
  console.log(`\u{1F30D} Browser origins ${describeAllowedOrigins()}`);
348
475
  console.log(` enforced on /<name>/${mcpSuffix}, /<name>/${sseSuffix} and /<name>/${messagesSuffix}`);
349
476
  if (!allowedOrigins) {
350
477
  console.log(` a page this broker serves is refused too, list its origin to admit it`);
351
478
  }
479
+ console.log(`\u{1F4C8} Provider telemetry ${otlpTracesEndpoint ? `OTLP/HTTP to ${otlpTracesEndpoint}` : "disabled"}`);
352
480
  console.log(hr);
353
481
  console.log(` New here? Call broker_guide on the ${BROKER_PROVIDER_NAME} slot: ${localhost}/${BROKER_PROVIDER_NAME}/${mcpSuffix}`);
354
482
  console.log(` Press Ctrl+C to stop.`);