@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.
- package/.mcp-broker.example/CONFIGURATION-EN.md +34 -0
- package/.mcp-broker.example/CONFIGURATION-FR.md +35 -0
- package/.mcp-broker.example/README.md +8 -0
- package/.mcp-broker.example/config.json +6 -0
- package/.mcp-broker.example/security.example.json +42 -0
- package/README.md +169 -1
- package/dist/bin.js +150 -22
- package/dist/bin.js.map +1 -1
- package/dist/chunk-BTWLF6KI.js +378 -0
- package/dist/chunk-BTWLF6KI.js.map +1 -0
- package/dist/{chunk-J5TN5RYU.js → chunk-K2EQP4RQ.js} +3211 -1138
- package/dist/chunk-K2EQP4RQ.js.map +1 -0
- package/dist/index.d.ts +342 -1996
- package/dist/index.js +2 -1
- package/dist/testing/index.d.ts +93 -0
- package/dist/testing/index.js +108 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/ws.tunnel.builder-BYCkbtA7.d.ts +2691 -0
- package/package.json +7 -2
- package/src/auth/index.ts +2 -1
- package/src/auth/provider.auth.ts +91 -0
- package/src/authority/broker.authority.ts +641 -0
- package/src/authority/declaration.ts +414 -0
- package/src/authorization/capability.classifier.ts +14 -1
- package/src/authorization/policy.types.ts +47 -1
- package/src/authorization/runtime.ts +8 -0
- package/src/bin.ts +181 -22
- package/src/broker/adapters/broker.adapter.info.ts +3 -0
- package/src/broker/adapters/broker.adapter.providers.ts +18 -0
- package/src/broker/aggregate/aggregate.server.ts +22 -1
- package/src/broker/aggregate/provider.client.session.ts +16 -8
- 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 +35 -0
- package/src/broker/broker.diagnostics.ts +103 -1
- package/src/broker/broker.guides.ts +149 -2
- package/src/config.ts +245 -11
- package/src/index.ts +70 -3
- package/src/subscriptions/resource.subscription.registry.ts +370 -0
- package/src/telemetry/index.ts +15 -0
- package/src/telemetry/otlp.http.exporter.ts +117 -0
- package/src/telemetry/telemetry.dispatcher.ts +231 -0
- package/src/telemetry/telemetry.types.ts +79 -0
- package/src/telemetry/trace.context.ts +50 -0
- package/src/testing/index.ts +230 -0
- package/src/ws/ws.interfaces.ts +95 -3
- package/src/ws/ws.tunnel.builder.ts +100 -2
- package/src/ws/ws.tunnel.ts +878 -43
- 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 {
|
|
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
|
-
|
|
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",
|
|
113
|
-
envFromConfig("MCP_BROKER_PUBLIC_BASE_URL",
|
|
114
|
-
envFromConfig("MCP_BROKER_JWKS",
|
|
115
|
-
envFromConfig("MCP_BROKER_ISSUER",
|
|
116
|
-
envFromConfig("MCP_BROKER_PROVIDER_SECRET",
|
|
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 =
|
|
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:
|
|
300
|
-
requiredScopes:
|
|
301
|
-
perSlotScopes:
|
|
302
|
-
providerScopes:
|
|
303
|
-
subjectMapping:
|
|
304
|
-
roles:
|
|
305
|
-
assignments:
|
|
306
|
-
denies:
|
|
307
|
-
slotResources:
|
|
308
|
-
toolCapabilities:
|
|
309
|
-
providerToolCapabilities:
|
|
310
|
-
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
|
-
|
|
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
|
-
|
|
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.`);
|