@cyanmycelium/mcp-broker-provider 0.1.1 → 0.3.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/README.md CHANGED
@@ -36,7 +36,7 @@ A multiplexed tunnel socket carries traffic for several providers at once, so ev
36
36
 
37
37
  Beyond the envelope it also covers:
38
38
 
39
- - `notifications/register`, sent as soon as the tunnel opens to claim a provider slot. Without it the broker only learns a provider name on its first real message, and an MCP client connecting in between is told the provider is not connected.
39
+ - `notifications/register`, sent as soon as the tunnel opens to claim a provider slot. Without it the broker only learns a provider name on its first real message, and an MCP client connecting in between is told the provider is not connected. It optionally carries `params: { aggregate: true }`, which also joins the broker's `_all` slot, see [Joining the `_all` aggregate slot](#joining-the-_all-aggregate-slot).
40
40
  - The tunnel error codes: `-32001` when the provider's credentials do not allow publishing on the requested slot, `-32000` when the slot is unavailable.
41
41
 
42
42
  When the broker refuses a slot, the transport surfaces it through `onError` rather than forwarding it. An id-less error frame handed to an MCP server would be classified as an unknown notification and dropped without a word, so the publisher would never learn it was refused.
@@ -45,10 +45,12 @@ Malformed frames decode to `undefined` rather than throwing: a tunnel socket is
45
45
 
46
46
  ## Transports
47
47
 
48
- | Transport | Use case |
49
- |---|---|
50
- | `MultiplexTransport` | Several servers published by one application. A single socket carries them all, keyed by slot name |
51
- | `DirectTransport` | One server, one socket, on `ws://<broker>/provider/<name>` |
48
+ | Transport | Endpoint it speaks to | Framing | Reconnects | Use case |
49
+ |---|---|---|---|---|
50
+ | `MultiplexTransport` | the shared multiplex base, `ws://<broker>/providers` | envelopes `{ provider, payload }` | yes, on the shared socket | Several servers published by one application. A single socket carries them all, keyed by slot name |
51
+ | `DirectTransport` | a slot-scoped path, `ws://<broker>/provider/<name>` | plain JSON-RPC frames | **no** | One server, one socket |
52
+
53
+ **The transport and the path are a pair, not a preference.** The broker decides framing from the endpoint the socket landed on, so a mismatch does not fail the handshake: the socket opens, looks healthy, and every frame is dropped on one side or the other with nothing logged by the broker. Both transports warn on the console when they spot the mismatch at connect time. And `ws://<broker>/providers/<name>`, the natural-looking blend of the two, is neither: the broker accepts it as a *client* connection on a slot of that name.
52
54
 
53
55
  ```ts
54
56
  import { McpServerBuilder } from "@cyanmycelium/mcp-core/server";
@@ -63,13 +65,157 @@ const server = new McpServerBuilder()
63
65
  await server.start();
64
66
  ```
65
67
 
66
- Transports created for the same tunnel URL share one WebSocket, whichever order they are opened in. Reconnection is handled by that shared socket, with exponential back-off and jitter; individual transports never reconnect on their own.
68
+ Transports created for the same tunnel URL share one WebSocket, whichever order they are opened in. Reconnection is handled by that shared socket, with exponential back-off and jitter, and individual transports never reconnect on their own. `DirectTransport` does not reconnect at all: when its socket closes it stays closed, and the application decides whether to call `connect()` again.
69
+
70
+ `server.start()` resolving means the transport reported itself open, not that the broker accepted the slot. A refusal arrives afterwards, and reaches you as an `onError` on the transport and a line on the console.
67
71
 
68
72
  `@cyanmycelium/mcp-core` is a peer dependency: the transports import its `IMessageTransport` type and nothing else at runtime, so your application keeps a single copy of it.
69
73
 
74
+ ## Joining the `_all` aggregate slot
75
+
76
+ The broker publishes an aggregate slot, `_all`, which exposes every opted-in provider's tools and prompts through one MCP connection. Membership is opt-in, because `_all` is a confidentiality boundary: a provider that does not ask for it stays reachable only on its own slot.
77
+
78
+ ```ts
79
+ import { DirectTransport, MultiplexTransport } from "@cyanmycelium/mcp-broker-provider";
80
+
81
+ // One socket per server
82
+ const direct = new DirectTransport("ws://localhost:3000/provider/scene-1", { aggregate: true });
83
+
84
+ // Or on the shared tunnel
85
+ const shared = MultiplexTransport.create("scene-1", "ws://localhost:3000/providers", { aggregate: true });
86
+ ```
87
+
88
+ Either form sends the registration notification with `params: { aggregate: true }` as its first frame:
89
+
90
+ ```json
91
+ { "jsonrpc": "2.0", "method": "notifications/register", "params": { "aggregate": true } }
92
+ ```
93
+
94
+ `DirectTransport` sends it verbatim, since the slot-scoped path carries plain JSON-RPC and the broker already knows the slot name from the URL. `MultiplexTransport` sends it inside the usual envelope, as `{ "provider": "scene-1", "payload": { ... } }`.
95
+
96
+ **Wire the message handler before you connect.** The broker runs `initialize` against a newly aggregated provider immediately, and a provider that does not answer is dropped from `_all` silently. Handing the transport to an MCP server does this for you, since the server assigns `onMessage` before calling `connect()`. Assigning it yourself, after connecting, loses the handshake and the provider never appears in the aggregate.
97
+
98
+ ## Letting the broker decide (broker 1.5.0)
99
+
100
+ A provider that serves its own kind of resource can declare an authorization
101
+ domain and ask the broker for decisions, instead of carrying a policy of its
102
+ own. Both transports expose the broker's methods as `transport.broker`:
103
+
104
+ ```ts
105
+ import { DirectTransport, callerReferenceOf } from "@cyanmycelium/mcp-broker-provider";
106
+
107
+ const transport = new DirectTransport("ws://broker:3000/provider/scada");
108
+ // ... start the MCP server on it, then:
109
+ await transport.broker.declare({
110
+ version: "2026-10-01.1",
111
+ domain: "scada",
112
+ namespace: { resource: "/production/site1" },
113
+ capabilities: ["scada.observe", "scada.control"],
114
+ resources: [
115
+ {
116
+ resource: "uns://production/site1/line1/motor01/speed_sp",
117
+ resourcePath: "/production/site1/line1/motor01/speed_sp",
118
+ limits: { minValue: 0, maxValue: 1500 },
119
+ },
120
+ ],
121
+ resultsRequired: ["scada.control"],
122
+ });
123
+
124
+ // In a tool handler (mcp-core 1.4.0 hands the adapter the request's _meta):
125
+ const caller = callerReferenceOf(request?.meta);
126
+ const { decisions } = await transport.broker.authorize({
127
+ principal: { type: "caller-ref", ref: caller!.ref },
128
+ checks: [{ capability: "scada.control", resource: "uns://production/site1/line1/motor01/speed_sp", resourcePath: "/production/site1/line1/motor01/speed_sp" }],
129
+ });
130
+ // decisions[0]: { effect: "allow-with-constraints", allowed: false, obligations: { constraints: { minValue: 0, maxValue: 1500 } }, ... }
131
+
132
+ // Once the write is done, or refused by a constraint:
133
+ transport.broker.reportResult({ decisionId: decisions[0].decisionId, result: "success", nativeStatus: "Good" });
134
+ ```
135
+
136
+ - `effect` is `allow`, `deny` or `allow-with-constraints`. The last one comes
137
+ with the resource's declared limits in `obligations.constraints`; apply them
138
+ right before executing. `allowed` is `true` only for a plain `allow`, so code
139
+ that reads `allowed` alone refuses a constrained allow instead of ignoring
140
+ its limits.
141
+ - `reportResult()` is a notification: nothing comes back. The broker writes the
142
+ outcome next to the decision in its audit, and `broker_diagnose` reports
143
+ decisions of `resultsRequired` capabilities still unreported.
144
+
145
+ - The provider needs its own identity on the broker (an entry in the security
146
+ file's `providers` table), which means presenting a secret:
147
+ `new DirectTransport(url, { secret })`, sent as `X-Provider-Token`. Node 22
148
+ and later can; a browser cannot, so this is for Node and native providers.
149
+ - To test all of this without an authorization server, start the broker with
150
+ `startTestBroker()` from `@cyanmycelium/mcp-broker/testing`
151
+ ([guide](../broker/docs/testing.md)).
152
+ - The broker's answers are taken off the socket before the MCP server sees
153
+ them. A refused request rejects with `BrokerRequestError` (`code`, `data`).
154
+ - There is no timeout by default: a broker from 1.4.1 on answers at once. Set
155
+ `brokerRequestTimeoutMs` only to talk to an older one, which drops methods it
156
+ does not know.
157
+
158
+ ## Distributed traces (broker 1.5.0)
159
+
160
+ Every addressed request arriving from broker 1.5 carries W3C Trace Context in
161
+ `params._meta.traceparent`. Read it from the request metadata, create a SERVER
162
+ span, then propagate a child context when this provider calls another slot:
163
+
164
+ ```ts
165
+ import {
166
+ childTraceparent,
167
+ traceparentOf,
168
+ withTraceparent,
169
+ } from "@cyanmycelium/mcp-broker-provider";
170
+
171
+ const incoming = traceparentOf(request?.meta);
172
+ const downstreamMeta = incoming
173
+ ? withTraceparent(undefined, childTraceparent(incoming, clientSpanId))
174
+ : undefined;
175
+ ```
176
+
177
+ Completed spans are validated before emission:
178
+
179
+ ```ts
180
+ const emitted = transport.broker.span({
181
+ traceId,
182
+ spanId,
183
+ parentSpanId,
184
+ name: "modbus.read",
185
+ kind: 3,
186
+ startTimeUnixNano,
187
+ endTimeUnixNano,
188
+ attributes: { "modbus.unit_id": 1 },
189
+ });
190
+ ```
191
+
192
+ The call is a notification and never waits for an answer. The broker consumes
193
+ `broker/telemetry`, sends it through its bounded exporter queue, and never
194
+ broadcasts it to MCP clients. It returns `false` and drops the span when the
195
+ provider link is down, so telemetry never occupies the transport queue ahead
196
+ of MCP control traffic. See [the complete telemetry contract](../../../docs/telemetry.md).
197
+
198
+ ## Diagnostics
199
+
200
+ This stack used to fail quietly. The transports now name what went wrong, on the console, because a browser-hosted provider has nowhere else to report:
201
+
202
+ - A frame written before the socket is open is queued (64 frames, oldest dropped first with a warning) and flushed on open, rather than discarded. That window covers the whole reconnect back-off, up to 30 seconds.
203
+ - A close with a code other than `1000` is reported through `onError` with the code and the broker's own reason, before `onClose`. A `1008` is a policy refusal: the slot is already connected, is reserved, or provider authentication rejected it.
204
+ - An incoming frame that is not an envelope, or one for a slot this socket does not publish, is logged with the likely cause and the fix. Repeats are sampled (the first in full, then one in fifty) so a mismatched tunnel cannot flood the console.
205
+
206
+ ## In a browser
207
+
208
+ The transports are written for the browser, but the npm path needs a bundler: neither this package nor `@cyanmycelium/mcp-core` ships a UMD build or declares a `browser` field, so a bare `<script>` tag will not load them. Any bundler works; there is nothing to configure beyond resolving the two packages.
209
+
210
+ Import only the isomorphic entry points. `@cyanmycelium/mcp-core/server` and `@cyanmycelium/mcp-core/client` run in a browser; `@cyanmycelium/mcp-core/node` does not, and pulling it in is the usual cause of a build that fails on Node built-ins.
211
+
212
+ If you would rather not add a build step, the broker ships a dependency-free ES module that speaks the same tunnel, `web/js/lib/broker-tunnel.js` (inside the `@cyanmycelium/mcp-broker` package, at `node/packages/broker/web` in the repository). It is the zero-build alternative, not a replacement: it carries no MCP server implementation.
213
+
214
+ One limitation worth knowing before you deploy: if the broker is configured with provider authentication, a browser-hosted provider cannot connect. The broker reads its credential from the `X-Provider-Token` or `Authorization` header of the upgrade request, and the browser `WebSocket` constructor cannot set headers, so the handshake is refused with a 401 the page sees only as a generic error. A browser provider needs provider auth off, or an authenticating reverse proxy in front of the broker.
215
+
70
216
  ## Status
71
217
 
72
- `0.1.0` ships the protocol module and both transports. They still exist in `@cyanmycelium/mcp-core@0.4.x` as well, and are removed there in `0.5.0`, migrate your imports before upgrading.
218
+ This package is the only home of the tunnel transports. They also shipped in `@cyanmycelium/mcp-core@0.4.x`, were removed there in `0.5.0`, and are absent from `0.7.x` and `1.x`, so migrate those imports here before upgrading `mcp-core`.
73
219
 
74
220
  The protocol is shared with the broker, and later with the consumer side. Import it through the `./protocol` subpath rather than the package root, so it can move to a package of its own one day without touching your call sites.
75
221
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,279 @@
1
- export { TUNNEL_REGISTER_METHOD, TunnelEnvelope, TunnelError, TunnelErrorCode, TunnelErrorCodes, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, envelopeFrame, tunnelErrorOf } from './protocol/index.js';
1
+ export { ITunnelRegisterOptions, TUNNEL_REGISTER_METHOD, TunnelEnvelope, TunnelError, TunnelErrorCode, TunnelErrorCodes, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, encodeRegisterFrame, envelopeFrame, tunnelErrorOf } from './protocol/index.js';
2
2
  import { IMessageTransport } from '@cyanmycelium/mcp-core';
3
3
 
4
+ /**
5
+ * Talks to the broker itself, over the provider's own socket: declaring an
6
+ * authorization domain, and asking for decisions.
7
+ *
8
+ * Requires broker 1.5.0 or later. An older broker does not know these methods:
9
+ * from 1.4.1 it refuses them at once with `-32601`; 1.4.0 and earlier drop
10
+ * them and never answer, which is why {@link IBrokerClientOptions.requestTimeoutMs}
11
+ * exists, off by default.
12
+ */
13
+ /** The `params._meta` key under which the broker passes the caller reference with each request. */
14
+ declare const CALLER_META_KEY = "io.cyanmycelium/caller";
15
+ /** W3C Trace Context carrier used by MCP requests. */
16
+ declare const TRACEPARENT_META_KEY = "traceparent";
17
+ /** Provider-to-broker telemetry notification. */
18
+ declare const TELEMETRY_NOTIFICATION_METHOD = "broker/telemetry";
19
+ /** Provider-to-broker notification reporting what happened after a decision. */
20
+ declare const AUDIT_RESULT_NOTIFICATION_METHOD = "broker/audit/result";
21
+ interface ITraceParent {
22
+ readonly version: "00";
23
+ readonly traceId: string;
24
+ readonly parentId: string;
25
+ readonly traceFlags: string;
26
+ }
27
+ type TelemetryAttributeValue = string | number | boolean;
28
+ interface ITelemetryEvent {
29
+ readonly name: string;
30
+ readonly timeUnixNano: string;
31
+ readonly attributes?: Readonly<Record<string, TelemetryAttributeValue>>;
32
+ }
33
+ interface ITelemetrySpan {
34
+ readonly traceId: string;
35
+ readonly spanId: string;
36
+ readonly parentSpanId?: string;
37
+ readonly name: string;
38
+ readonly kind?: number;
39
+ readonly startTimeUnixNano: string;
40
+ readonly endTimeUnixNano: string;
41
+ readonly attributes?: Readonly<Record<string, TelemetryAttributeValue>>;
42
+ readonly events?: readonly ITelemetryEvent[];
43
+ readonly status?: {
44
+ readonly code: number;
45
+ readonly message?: string;
46
+ };
47
+ }
48
+ /** Parses the W3C version 00 traceparent representation used in MCP metadata. */
49
+ declare function parseTraceparent(value: unknown): ITraceParent | undefined;
50
+ declare function formatTraceparent(context: ITraceParent): string;
51
+ /** Reads `params._meta.traceparent`, returning `undefined` when it is malformed. */
52
+ declare function traceparentOf(meta: Readonly<Record<string, unknown>> | undefined): ITraceParent | undefined;
53
+ /** Returns a metadata copy carrying the supplied validated trace context. */
54
+ declare function withTraceparent(meta: Readonly<Record<string, unknown>> | undefined, context: ITraceParent): Readonly<Record<string, unknown>>;
55
+ /** Continues a trace using the caller's span as the new W3C parent id. */
56
+ declare function childTraceparent(parent: ITraceParent, spanId: string): ITraceParent;
57
+ /** What the broker hands a declaring provider with each request, under {@link CALLER_META_KEY}. */
58
+ interface ICallerReference {
59
+ /** Opaque. Valid on this slot, while the request it came with is pending. */
60
+ readonly ref: string;
61
+ /** Ties the decisions and the eventual report to the client request. */
62
+ readonly correlationId: string;
63
+ /** W3C trace id carried by the MCP request. */
64
+ readonly traceId?: string;
65
+ }
66
+ /**
67
+ * Reads the caller reference out of a request's `params._meta`, or
68
+ * `undefined` when there is none (the broker only adds it once a declaration
69
+ * was accepted). With mcp-core 1.4.0, pass `request?.meta` from the context
70
+ * an adapter receives.
71
+ */
72
+ declare function callerReferenceOf(meta: Readonly<Record<string, unknown>> | undefined): ICallerReference | undefined;
73
+ /**
74
+ * Engineering limits of one resource. They hold for every caller, and the
75
+ * broker returns them with each allow on that resource as
76
+ * `obligations.constraints`, for the provider to apply.
77
+ */
78
+ interface IResourceLimits {
79
+ readonly minValue?: number;
80
+ readonly maxValue?: number;
81
+ readonly allowedValues?: readonly (string | number | boolean | null)[];
82
+ readonly destinations?: readonly string[];
83
+ }
84
+ /** One resource of a declaration: the provider's own identifier, and the path the broker evaluates. */
85
+ interface IDeclaredResource {
86
+ readonly resource: string;
87
+ readonly resourcePath: string;
88
+ readonly effect?: string;
89
+ readonly limits?: IResourceLimits;
90
+ }
91
+ /** `broker/authorization/declare` parameters. Describes; grants nothing. */
92
+ interface IAuthorizationDeclaration {
93
+ readonly version: string;
94
+ readonly domain: string;
95
+ readonly namespace: {
96
+ readonly resource: string;
97
+ };
98
+ readonly capabilities: readonly string[];
99
+ readonly resources?: readonly IDeclaredResource[];
100
+ readonly protects?: readonly string[];
101
+ /**
102
+ * Declared capabilities whose allowed decisions this provider promises to
103
+ * report with {@link BrokerClient.reportResult}. One not reported in time
104
+ * shows up in `broker_diagnose`.
105
+ */
106
+ readonly resultsRequired?: readonly string[];
107
+ }
108
+ interface IDeclarationAccepted {
109
+ readonly accepted: true;
110
+ readonly version: string;
111
+ readonly policyVersion: string;
112
+ }
113
+ /** One question: may (the caller) do `capability` on this resource? */
114
+ interface IAuthorizationCheck {
115
+ readonly capability: string;
116
+ readonly resource: string;
117
+ readonly resourcePath: string;
118
+ readonly attributes?: Readonly<Record<string, unknown>>;
119
+ }
120
+ /** `broker/authorize` parameters. */
121
+ interface IAuthorizationQuery {
122
+ /** On whose behalf: the caller of a pending request, or the provider itself. Never an identity. */
123
+ readonly principal: {
124
+ readonly type: "caller-ref";
125
+ readonly ref: string;
126
+ } | {
127
+ readonly type: "provider";
128
+ };
129
+ /** Only for `{ type: "provider" }`; a caller reference carries its own. */
130
+ readonly correlationId?: string;
131
+ /** Optional W3C trace id for provider-initiated work. */
132
+ readonly traceId?: string;
133
+ readonly checks: readonly IAuthorizationCheck[];
134
+ }
135
+ /** What an allow comes with. */
136
+ interface IAuthorizationObligations {
137
+ /** The declared limits of the resource. Apply them right before executing. */
138
+ readonly constraints?: IResourceLimits;
139
+ }
140
+ interface IAuthorizationDecision {
141
+ readonly decisionId: string;
142
+ /** `allow-with-constraints`: allowed, within `obligations`. */
143
+ readonly effect: "allow" | "deny" | "allow-with-constraints";
144
+ /**
145
+ * `true` only for an unconditional `allow`. A provider that reads only
146
+ * this field therefore refuses a constrained allow rather than ignoring
147
+ * its constraints; read `effect` to apply them.
148
+ */
149
+ readonly allowed: boolean;
150
+ readonly reason: string;
151
+ readonly policies?: readonly string[];
152
+ readonly obligations?: IAuthorizationObligations;
153
+ }
154
+ /** `broker/audit/result` parameters: the outcome of what a decision allowed or refused. */
155
+ interface IAuditResult {
156
+ /** The `decisionId` the broker returned. */
157
+ readonly decisionId: string;
158
+ readonly result: "success" | "failure" | "refused";
159
+ /** The protocol's own status (`Good`, an exception code, ...). */
160
+ readonly nativeStatus?: string;
161
+ /** The provider's error code, when it failed or refused. */
162
+ readonly errorCode?: string;
163
+ }
164
+ interface IAuthorizationAnswer {
165
+ readonly policyVersion: string;
166
+ /** One per check, in the same order. */
167
+ readonly decisions: readonly IAuthorizationDecision[];
168
+ }
169
+ /** The broker refused a request, or never answered it. */
170
+ declare class BrokerRequestError extends Error {
171
+ /** JSON-RPC error code; `undefined` for a timeout or a closed socket. */
172
+ readonly code?: number | undefined;
173
+ /** The error's `data`, e.g. `{ errors: [...] }` for a refused declaration. */
174
+ readonly data?: unknown | undefined;
175
+ constructor(message: string,
176
+ /** JSON-RPC error code; `undefined` for a timeout or a closed socket. */
177
+ code?: number | undefined,
178
+ /** The error's `data`, e.g. `{ errors: [...] }` for a refused declaration. */
179
+ data?: unknown | undefined);
180
+ }
181
+ interface IBrokerClientOptions {
182
+ /**
183
+ * Rejects a request the broker did not answer within this many ms. Off by
184
+ * default: a broker from 1.4.1 on answers every request at once, refusals
185
+ * included, so waiting is never the normal path. Set it only to talk to an
186
+ * older broker, which drops what it does not know.
187
+ */
188
+ readonly requestTimeoutMs?: number;
189
+ }
190
+ /**
191
+ * The `broker/*` methods of one provider slot. Reached as `transport.broker`
192
+ * on {@link DirectTransport} and {@link MultiplexTransport}; the transport
193
+ * routes the broker's answers here before anything reaches the MCP server.
194
+ */
195
+ declare class BrokerClient {
196
+ private readonly _write;
197
+ private readonly _writeTelemetry;
198
+ private readonly _timeoutMs;
199
+ private readonly _waiting;
200
+ private _next;
201
+ constructor(write: (frame: string) => void, options?: IBrokerClientOptions, writeTelemetry?: (frame: string) => boolean);
202
+ /**
203
+ * Declares this provider's authorization domain. Resolves when the broker
204
+ * accepted it; rejects with a {@link BrokerRequestError} whose `data.errors`
205
+ * lists every problem otherwise. Until it resolves, serve nothing that
206
+ * needs a decision.
207
+ */
208
+ declare(declaration: IAuthorizationDeclaration): Promise<IDeclarationAccepted>;
209
+ /** Asks for one decision per check. */
210
+ authorize(query: IAuthorizationQuery): Promise<IAuthorizationAnswer>;
211
+ /**
212
+ * Reports what happened after a decision, so the broker's audit shows the
213
+ * outcome next to the decision. A notification: nothing comes back, and a
214
+ * report the broker cannot match is counted on its side.
215
+ */
216
+ reportResult(report: IAuditResult): void;
217
+ /** Emits one complete provider span, or returns false when the link is down. */
218
+ span(span: ITelemetrySpan): boolean;
219
+ /** Number of requests still waiting for the broker. */
220
+ get pendingCount(): number;
221
+ /**
222
+ * Consumes a frame when it answers one of this client's requests. Returns
223
+ * `true` when it did, and the frame must not reach the MCP server.
224
+ * @internal Called by the transports.
225
+ */
226
+ handleIncoming(frame: string): boolean;
227
+ /**
228
+ * Fails every waiting request: the socket they went out on is gone, and a
229
+ * reconnected one will not carry their answers.
230
+ * @internal Called by the transports.
231
+ */
232
+ rejectAll(reason: string): void;
233
+ private _request;
234
+ }
235
+
236
+ /** Options accepted by {@link DirectTransport}. */
237
+ interface IDirectTransportOptions {
238
+ /**
239
+ * Join the broker's `_all` aggregate slot as well as this provider's own
240
+ * slot, by sending the registration notification
241
+ * `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`
242
+ * as the first frame on the socket.
243
+ *
244
+ * Opt-in on purpose: `_all` exposes this provider's tools and prompts to
245
+ * every client of the aggregate slot, so a provider that does not ask for it
246
+ * stays reachable only on its own slot.
247
+ *
248
+ * ORDERING: the broker runs `initialize` against a newly aggregated provider
249
+ * immediately, and drops it from `_all` without a word if the handshake times
250
+ * out. The frame therefore goes out on `open`, never from the constructor, so
251
+ * assign `onMessage` (or hand this transport to an MCP server, which assigns
252
+ * it for you) *before* calling {@link DirectTransport.connect}. Connecting
253
+ * first and wiring the handler afterwards loses the broker's `initialize` and
254
+ * the provider silently never appears in `_all`.
255
+ */
256
+ aggregate?: boolean;
257
+ /**
258
+ * Rejects a `broker.declare()` / `broker.authorize()` the broker did not
259
+ * answer within this many ms. Off by default: a broker from 1.4.1 on answers
260
+ * at once. Only for an older broker, which drops methods it does not know.
261
+ */
262
+ brokerRequestTimeoutMs?: number;
263
+ /**
264
+ * The provider secret, sent as the `X-Provider-Token` header of the
265
+ * WebSocket handshake. Required by a broker that authenticates providers
266
+ * (`providerSecret`, or the security file's `providers` table, where it is
267
+ * what gives this provider its own identity).
268
+ *
269
+ * **Node only.** Node's `WebSocket` (22 and later) accepts handshake
270
+ * headers; a browser's does not, and a browser provider cannot
271
+ * authenticate (terminate provider auth in a reverse proxy instead).
272
+ */
273
+ secret?: string;
274
+ /** Extra handshake headers, Node only, like {@link secret}. */
275
+ headers?: Readonly<Record<string, string>>;
276
+ }
4
277
  /**
5
278
  * 1:1 WebSocket transport, wraps a single `WebSocket` connection to a broker
6
279
  * provider slot, typically `ws://<broker>/provider/<name>`.
@@ -9,16 +282,32 @@ import { IMessageTransport } from '@cyanmycelium/mcp-core';
9
282
  * through the same broker, prefer {@link MultiplexTransport}, which shares a
10
283
  * single socket between them.
11
284
  *
285
+ * This transport does **not** reconnect: when the socket closes, it stays
286
+ * closed until the application calls {@link connect} again. Only
287
+ * {@link MultiplexTransport}'s shared socket reconnects on its own.
288
+ *
12
289
  * Call {@link connect} after setting the event callbacks to open the socket.
13
290
  */
14
291
  declare class DirectTransport implements IMessageTransport {
15
292
  private readonly _wsUrl;
293
+ private readonly _aggregate;
294
+ private readonly _headers;
295
+ private readonly _pending;
296
+ /** Throttles the "wrote to a closed transport" line, which repeats per frame. */
297
+ private readonly _afterCloseNotice;
16
298
  private _ws;
299
+ private _closed;
17
300
  onMessage: ((data: string) => void) | null;
18
301
  onOpen: (() => void) | null;
19
302
  onClose: (() => void) | null;
20
303
  onError: ((error: Error) => void) | null;
21
- constructor(wsUrl: string);
304
+ /**
305
+ * The broker's own methods for this slot: declaring an authorization
306
+ * domain, asking for decisions. Its answers are taken off the socket
307
+ * before {@link onMessage} sees anything.
308
+ */
309
+ readonly broker: BrokerClient;
310
+ constructor(wsUrl: string, options?: IDirectTransportOptions);
22
311
  get isOpen(): boolean;
23
312
  /**
24
313
  * Opens the WebSocket connection and wires its events to the transport
@@ -27,6 +316,23 @@ declare class DirectTransport implements IMessageTransport {
27
316
  connect(): void;
28
317
  send(data: string): void;
29
318
  close(): void;
319
+ /**
320
+ * Sends the registration notification when the caller asked for a specific
321
+ * aggregate membership. The slot name is not in the frame: on the
322
+ * slot-scoped path the broker takes it from the URL at connect time.
323
+ */
324
+ private _sendRegistration;
325
+ /** Writes out everything queued while the socket was connecting. */
326
+ private _flush;
327
+ /**
328
+ * Builds the message for a close worth reporting, or `undefined` when there
329
+ * is nothing to say.
330
+ *
331
+ * A code of 1000 is a normal close and stays silent. An environment that
332
+ * calls `onclose` with no event at all leaves nothing to distinguish a
333
+ * refusal from a clean shutdown, so that stays silent too.
334
+ */
335
+ private _closeError;
30
336
  }
31
337
 
32
338
  /**
@@ -41,27 +347,100 @@ declare class MultiplexSocket {
41
347
  /** Per-URL cache so all transports targeting the same tunnel share one socket. */
42
348
  private static readonly _instances;
43
349
  private readonly _wsUrl;
350
+ /** Cache key: the URL, and the handshake headers, since two secrets are two identities and need two sockets. */
351
+ private readonly _key;
352
+ private readonly _headers;
44
353
  private readonly _transports;
354
+ /** Aggregate opt-in per slot, absent when the caller did not express one. */
355
+ private readonly _aggregates;
356
+ /** Frames written while the socket is connecting, reconnecting or backing off. */
357
+ private readonly _pending;
45
358
  private _ws;
46
359
  private _reconnectAttempts;
360
+ private _reconnectTimer;
47
361
  private _stopped;
362
+ /** Set once the last transport left and this instance gave up its URL. */
363
+ private _dead;
364
+ /** The URL guard is about the URL, not the socket, so it is said once. */
365
+ private _pathWarned;
366
+ /** Throttles the "wrote to a closed tunnel" line, which repeats per frame. */
367
+ private readonly _afterStopNotice;
48
368
  private constructor();
49
- /** Returns (or creates) the singleton socket for a given tunnel URL. */
50
- static getOrCreate(wsUrl: string): MultiplexSocket;
369
+ /** Returns (or creates) the singleton socket for a given tunnel URL and handshake headers. */
370
+ static getOrCreate(wsUrl: string, headers?: Record<string, string>): MultiplexSocket;
51
371
  get isOpen(): boolean;
52
- register(name: string, transport: MultiplexTransport): void;
372
+ register(name: string, transport: MultiplexTransport, aggregate?: boolean): void;
53
373
  unregister(name: string): void;
374
+ /**
375
+ * Brings a torn-down instance back into service when a transport that
376
+ * captured it is reactivated.
377
+ *
378
+ * Reclaims the URL when nothing else holds it. When another instance already
379
+ * does, the two would race for the same slot names, so this says exactly that
380
+ * rather than letting the loser fail with a refusal nobody reads.
381
+ */
382
+ private _revive;
54
383
  /**
55
384
  * Claims the slot for `name` so the broker eagerly creates its provider
56
385
  * state before any MCP client connects. Without it the broker only learns
57
386
  * about a provider on its first real message, and a client connecting in
58
387
  * between is told the provider is not connected.
388
+ *
389
+ * Carries the aggregate opt-in when the caller expressed one, which is why
390
+ * it must go out before any traffic: the broker runs `initialize` against a
391
+ * newly aggregated provider straight away.
59
392
  */
60
393
  private _announceProvider;
61
394
  send(provider: string, data: string): void;
395
+ /** Sends low-priority telemetry only while the shared link is open. */
396
+ sendTelemetry(provider: string, data: string): boolean;
62
397
  private _connect;
398
+ /** Writes out everything queued while the socket was down. */
399
+ private _flush;
63
400
  private _routeIncoming;
64
401
  private _scheduleReconnect;
402
+ /** Disarms a pending reconnect, so nothing opens a socket behind our back. */
403
+ private _cancelReconnect;
404
+ }
405
+ /** Options accepted by {@link MultiplexTransport.create}. */
406
+ interface IMultiplexTransportOptions {
407
+ /**
408
+ * Join the broker's `_all` aggregate slot as well as this provider's own
409
+ * slot, by carrying `params: { aggregate: true }` on the registration
410
+ * notification the shared socket sends when it claims the slot.
411
+ *
412
+ * Opt-in on purpose: `_all` exposes this provider's tools and prompts to
413
+ * every client of the aggregate slot, so a provider that does not ask for it
414
+ * stays reachable only on its own slot.
415
+ *
416
+ * ORDERING: the broker runs `initialize` against a newly aggregated provider
417
+ * immediately, and drops it from `_all` without a word if the handshake times
418
+ * out. The registration goes out from {@link MultiplexTransport.connect}, so
419
+ * assign `onMessage` (or hand this transport to an MCP server, which assigns
420
+ * it for you) *before* connecting. Connecting first and wiring the handler
421
+ * afterwards loses the broker's `initialize`, and the provider silently never
422
+ * appears in `_all`.
423
+ */
424
+ aggregate?: boolean;
425
+ /**
426
+ * Rejects a `broker.declare()` / `broker.authorize()` the broker did not
427
+ * answer within this many ms. Off by default: a broker from 1.4.1 on answers
428
+ * at once. Only for an older broker, which drops methods it does not know.
429
+ */
430
+ brokerRequestTimeoutMs?: number;
431
+ /**
432
+ * The provider secret, sent as the `X-Provider-Token` header of the
433
+ * WebSocket handshake. Required by a broker that authenticates providers
434
+ * (`providerSecret`, or the security file's `providers` table, where it is
435
+ * what gives this provider its own identity).
436
+ *
437
+ * **Node only.** Node's `WebSocket` (22 and later) accepts handshake
438
+ * headers; a browser's does not, and a browser provider cannot
439
+ * authenticate (terminate provider auth in a reverse proxy instead).
440
+ */
441
+ secret?: string;
442
+ /** Extra handshake headers, Node only, like {@link secret}. */
443
+ headers?: Readonly<Record<string, string>>;
65
444
  }
66
445
  /**
67
446
  * A transport that multiplexes multiple MCP servers over a single shared
@@ -77,19 +456,36 @@ declare class MultiplexSocket {
77
456
  declare class MultiplexTransport implements IMessageTransport {
78
457
  private readonly _name;
79
458
  private readonly _socket;
459
+ private readonly _aggregate;
80
460
  private _registered;
81
461
  onMessage: ((data: string) => void) | null;
82
462
  onOpen: (() => void) | null;
83
463
  onClose: (() => void) | null;
84
464
  onError: ((error: Error) => void) | null;
85
- constructor(name: string, socket: MultiplexSocket);
465
+ /**
466
+ * The broker's own methods for this slot: declaring an authorization
467
+ * domain, asking for decisions. Its answers are taken off the socket
468
+ * before {@link onMessage} sees anything.
469
+ */
470
+ readonly broker: BrokerClient;
471
+ constructor(name: string, socket: MultiplexSocket, options?: IMultiplexTransportOptions);
472
+ /**
473
+ * One frame from the broker for this slot: the broker's answer to one of
474
+ * {@link broker}'s requests, or MCP traffic for the server.
475
+ * @internal Called by the shared socket.
476
+ */
477
+ _receive(frame: string): void;
86
478
  /**
87
479
  * Convenience factory: creates a {@link MultiplexTransport} backed by a
88
480
  * shared {@link MultiplexSocket} for the given tunnel URL.
89
481
  *
90
482
  * Transports targeting the same `wsUrl` automatically share one WebSocket.
483
+ *
484
+ * @param wsUrl The broker's shared multiplex base, `ws://<broker>/providers`.
485
+ * A slot-scoped `/provider/<name>` URL belongs to
486
+ * {@link DirectTransport} and is warned about on connect.
91
487
  */
92
- static create(name: string, wsUrl: string): MultiplexTransport;
488
+ static create(name: string, wsUrl: string, options?: IMultiplexTransportOptions): MultiplexTransport;
93
489
  get isOpen(): boolean;
94
490
  /**
95
491
  * Registers this transport with the shared socket.
@@ -109,4 +505,4 @@ declare class MultiplexTransport implements IMessageTransport {
109
505
  close(): void;
110
506
  }
111
507
 
112
- export { DirectTransport, MultiplexTransport };
508
+ export { AUDIT_RESULT_NOTIFICATION_METHOD, BrokerClient, BrokerRequestError, CALLER_META_KEY, DirectTransport, type IAuditResult, type IAuthorizationAnswer, type IAuthorizationCheck, type IAuthorizationDecision, type IAuthorizationDeclaration, type IAuthorizationObligations, type IAuthorizationQuery, type IBrokerClientOptions, type ICallerReference, type IDeclarationAccepted, type IDeclaredResource, type IDirectTransportOptions, type IMultiplexTransportOptions, type IResourceLimits, type ITelemetryEvent, type ITelemetrySpan, type ITraceParent, MultiplexTransport, TELEMETRY_NOTIFICATION_METHOD, TRACEPARENT_META_KEY, type TelemetryAttributeValue, callerReferenceOf, childTraceparent, formatTraceparent, parseTraceparent, traceparentOf, withTraceparent };