@cyanmycelium/mcp-broker-provider 0.2.0 → 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/dist/index.js CHANGED
@@ -53,6 +53,177 @@ function tunnelErrorOf(payload) {
53
53
  return error;
54
54
  }
55
55
 
56
+ // src/broker.client.ts
57
+ var CALLER_META_KEY = "io.cyanmycelium/caller";
58
+ var TRACEPARENT_META_KEY = "traceparent";
59
+ var TELEMETRY_NOTIFICATION_METHOD = "broker/telemetry";
60
+ var AUDIT_RESULT_NOTIFICATION_METHOD = "broker/audit/result";
61
+ var TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
62
+ var TRACE_ID = /^[0-9a-f]{32}$/;
63
+ var SPAN_ID = /^[0-9a-f]{16}$/;
64
+ var UNIX_NANO = /^(0|[1-9][0-9]{0,19})$/;
65
+ function parseTraceparent(value) {
66
+ if (typeof value !== "string") return void 0;
67
+ const match = TRACEPARENT.exec(value);
68
+ if (!match || /^0+$/.test(match[1]) || /^0+$/.test(match[2])) return void 0;
69
+ return { version: "00", traceId: match[1], parentId: match[2], traceFlags: match[3] };
70
+ }
71
+ function formatTraceparent(context) {
72
+ return `${context.version}-${context.traceId}-${context.parentId}-${context.traceFlags}`;
73
+ }
74
+ function traceparentOf(meta) {
75
+ return parseTraceparent(meta?.[TRACEPARENT_META_KEY]);
76
+ }
77
+ function withTraceparent(meta, context) {
78
+ const encoded = formatTraceparent(context);
79
+ if (!parseTraceparent(encoded)) throw new TypeError("traceparent must be a valid W3C version 00 context");
80
+ return { ...meta ?? {}, [TRACEPARENT_META_KEY]: encoded };
81
+ }
82
+ function childTraceparent(parent, spanId) {
83
+ if (!SPAN_ID.test(spanId) || /^0+$/.test(spanId)) throw new TypeError("spanId must be 16 lowercase hexadecimal characters and not all zero");
84
+ return { ...parent, parentId: spanId };
85
+ }
86
+ function validAttributes(attributes) {
87
+ if (attributes === void 0) return true;
88
+ const entries = Object.entries(attributes);
89
+ return entries.length <= 64 && entries.every(
90
+ ([key, value]) => key.length > 0 && key.length <= 128 && (typeof value === "boolean" || typeof value === "string" && value.length <= 4096 || typeof value === "number" && Number.isFinite(value))
91
+ );
92
+ }
93
+ function validSpan(span) {
94
+ return TRACE_ID.test(span.traceId) && !/^0+$/.test(span.traceId) && SPAN_ID.test(span.spanId) && !/^0+$/.test(span.spanId) && (span.parentSpanId === void 0 || SPAN_ID.test(span.parentSpanId) && !/^0+$/.test(span.parentSpanId)) && span.name.length > 0 && span.name.length <= 256 && UNIX_NANO.test(span.startTimeUnixNano) && UNIX_NANO.test(span.endTimeUnixNano) && (span.kind === void 0 || Number.isInteger(span.kind) && span.kind >= 0 && span.kind <= 5) && validAttributes(span.attributes) && (span.events === void 0 || span.events.length <= 32 && span.events.every((event) => event.name.length > 0 && event.name.length <= 256 && UNIX_NANO.test(event.timeUnixNano) && validAttributes(event.attributes))) && (span.status === void 0 || Number.isInteger(span.status.code) && span.status.code >= 0 && span.status.code <= 2 && (span.status.message === void 0 || span.status.message.length <= 1024));
95
+ }
96
+ function callerReferenceOf(meta) {
97
+ const value = meta?.[CALLER_META_KEY];
98
+ if (typeof value !== "object" || value === null) return void 0;
99
+ const { ref, correlationId, traceId } = value;
100
+ return typeof ref === "string" && typeof correlationId === "string" ? { ref, correlationId, ...typeof traceId === "string" ? { traceId } : {} } : void 0;
101
+ }
102
+ var BrokerRequestError = class extends Error {
103
+ constructor(message, code, data) {
104
+ super(message);
105
+ this.code = code;
106
+ this.data = data;
107
+ this.name = "BrokerRequestError";
108
+ }
109
+ code;
110
+ data;
111
+ };
112
+ var ID_PREFIX = "provider-broker-";
113
+ var BrokerClient = class {
114
+ _write;
115
+ _writeTelemetry;
116
+ _timeoutMs;
117
+ _waiting = /* @__PURE__ */ new Map();
118
+ _next = 1;
119
+ constructor(write, options = {}, writeTelemetry) {
120
+ this._write = write;
121
+ this._writeTelemetry = writeTelemetry ?? ((frame) => {
122
+ write(frame);
123
+ return true;
124
+ });
125
+ this._timeoutMs = Math.max(0, options.requestTimeoutMs ?? 0);
126
+ }
127
+ /**
128
+ * Declares this provider's authorization domain. Resolves when the broker
129
+ * accepted it; rejects with a {@link BrokerRequestError} whose `data.errors`
130
+ * lists every problem otherwise. Until it resolves, serve nothing that
131
+ * needs a decision.
132
+ */
133
+ declare(declaration) {
134
+ return this._request("broker/authorization/declare", declaration);
135
+ }
136
+ /** Asks for one decision per check. */
137
+ authorize(query) {
138
+ return this._request("broker/authorize", query);
139
+ }
140
+ /**
141
+ * Reports what happened after a decision, so the broker's audit shows the
142
+ * outcome next to the decision. A notification: nothing comes back, and a
143
+ * report the broker cannot match is counted on its side.
144
+ */
145
+ reportResult(report) {
146
+ if (typeof report?.decisionId !== "string" || report.decisionId.length === 0) throw new TypeError("decisionId must be the non-empty id the broker returned");
147
+ if (report.result !== "success" && report.result !== "failure" && report.result !== "refused") throw new TypeError('result must be "success", "failure" or "refused"');
148
+ const params = {
149
+ decisionId: report.decisionId,
150
+ result: report.result,
151
+ ...report.nativeStatus !== void 0 ? { nativeStatus: report.nativeStatus } : {},
152
+ ...report.errorCode !== void 0 ? { errorCode: report.errorCode } : {}
153
+ };
154
+ this._write(JSON.stringify({ jsonrpc: "2.0", method: AUDIT_RESULT_NOTIFICATION_METHOD, params }));
155
+ }
156
+ /** Emits one complete provider span, or returns false when the link is down. */
157
+ span(span) {
158
+ if (!validSpan(span)) throw new TypeError("span is not a valid broker telemetry span");
159
+ return this._writeTelemetry(
160
+ JSON.stringify({
161
+ jsonrpc: "2.0",
162
+ method: TELEMETRY_NOTIFICATION_METHOD,
163
+ params: { version: 1, signal: "traces", span }
164
+ })
165
+ );
166
+ }
167
+ /** Number of requests still waiting for the broker. */
168
+ get pendingCount() {
169
+ return this._waiting.size;
170
+ }
171
+ /**
172
+ * Consumes a frame when it answers one of this client's requests. Returns
173
+ * `true` when it did, and the frame must not reach the MCP server.
174
+ * @internal Called by the transports.
175
+ */
176
+ handleIncoming(frame) {
177
+ if (this._waiting.size === 0 || !frame.includes(ID_PREFIX)) return false;
178
+ let message;
179
+ try {
180
+ message = JSON.parse(frame);
181
+ } catch {
182
+ return false;
183
+ }
184
+ if (typeof message.id !== "string" || message.method !== void 0) return false;
185
+ const waiting = this._waiting.get(message.id);
186
+ if (!waiting) return false;
187
+ this._waiting.delete(message.id);
188
+ if (waiting.timer) clearTimeout(waiting.timer);
189
+ if (message.error) {
190
+ waiting.reject(
191
+ new BrokerRequestError(String(message.error.message ?? "broker error"), typeof message.error.code === "number" ? message.error.code : void 0, message.error.data)
192
+ );
193
+ } else {
194
+ waiting.resolve(message.result);
195
+ }
196
+ return true;
197
+ }
198
+ /**
199
+ * Fails every waiting request: the socket they went out on is gone, and a
200
+ * reconnected one will not carry their answers.
201
+ * @internal Called by the transports.
202
+ */
203
+ rejectAll(reason) {
204
+ for (const [id, waiting] of this._waiting) {
205
+ if (waiting.timer) clearTimeout(waiting.timer);
206
+ waiting.reject(new BrokerRequestError(`${reason} (request ${id})`));
207
+ }
208
+ this._waiting.clear();
209
+ }
210
+ _request(method, params) {
211
+ const id = `${ID_PREFIX}${this._next++}`;
212
+ return new Promise((resolve, reject) => {
213
+ const timer = this._timeoutMs > 0 ? setTimeout(() => {
214
+ this._waiting.delete(id);
215
+ reject(
216
+ new BrokerRequestError(
217
+ `The broker did not answer ${method} within ${this._timeoutMs}ms. A broker older than 1.4.1 drops methods it does not know; ${method} needs broker 1.5.0 or later.`
218
+ )
219
+ );
220
+ }, this._timeoutMs) : null;
221
+ this._waiting.set(id, { resolve, reject, timer });
222
+ this._write(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
223
+ });
224
+ }
225
+ };
226
+
56
227
  // src/transport.support.ts
57
228
  var PENDING_FRAME_LIMIT = 64;
58
229
  var PendingFrames = class {
@@ -173,11 +344,32 @@ function warnIfSlotScopedPath(wsUrl) {
173
344
  `[mcp-provider] MultiplexTransport is connecting to "${wsUrl}", whose path "${path}" is a slot-scoped provider endpoint. That endpoint carries plain JSON-RPC frames, while MultiplexTransport writes multiplex envelopes { provider, payload }, which the broker never unwraps, so nothing sent here is answered. Either publish through new DirectTransport("${wsUrl}"), or point MultiplexTransport at the shared multiplex base "${withPath(url, DEFAULT_MULTIPLEX_PATH)}". ` + RECONFIGURED_HINT
174
345
  );
175
346
  }
347
+ function handshakeHeaders(options) {
348
+ const headers = { ...options?.headers ?? {} };
349
+ if (options?.secret) headers["x-provider-token"] = options.secret;
350
+ return Object.keys(headers).length > 0 ? headers : void 0;
351
+ }
352
+ function openWebSocket(url, headers) {
353
+ if (typeof WebSocket === "undefined") {
354
+ throw new Error(
355
+ `Cannot open ${url}: this runtime has no global WebSocket (Node before 22). Assign one before connecting: import { WebSocket } from "ws"; globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;`
356
+ );
357
+ }
358
+ if (!headers) return new WebSocket(url);
359
+ try {
360
+ return new WebSocket(url, { headers });
361
+ } catch (error) {
362
+ throw new Error(
363
+ `Cannot open ${url} with handshake headers (secret / headers): this runtime's WebSocket does not accept them. Node 22 and later do; a browser never does, so a browser provider cannot authenticate to the broker. ${error.message}`
364
+ );
365
+ }
366
+ }
176
367
 
177
368
  // src/direct.transport.ts
178
369
  var DirectTransport = class {
179
370
  _wsUrl;
180
371
  _aggregate;
372
+ _headers;
181
373
  _pending;
182
374
  /** Throttles the "wrote to a closed transport" line, which repeats per frame. */
183
375
  _afterCloseNotice = new ThrottledNotice();
@@ -187,10 +379,26 @@ var DirectTransport = class {
187
379
  onOpen = null;
188
380
  onClose = null;
189
381
  onError = null;
382
+ /**
383
+ * The broker's own methods for this slot: declaring an authorization
384
+ * domain, asking for decisions. Its answers are taken off the socket
385
+ * before {@link onMessage} sees anything.
386
+ */
387
+ broker;
190
388
  constructor(wsUrl, options) {
191
389
  this._wsUrl = wsUrl;
192
390
  this._aggregate = options?.aggregate;
391
+ this._headers = handshakeHeaders(options);
193
392
  this._pending = new PendingFrames(`DirectTransport ${wsUrl}`);
393
+ this.broker = new BrokerClient(
394
+ (frame) => this.send(frame),
395
+ { requestTimeoutMs: options?.brokerRequestTimeoutMs },
396
+ (frame) => {
397
+ if (this._ws?.readyState !== WebSocket.OPEN) return false;
398
+ this._ws.send(frame);
399
+ return true;
400
+ }
401
+ );
194
402
  }
195
403
  get isOpen() {
196
404
  return this._ws?.readyState === WebSocket.OPEN;
@@ -201,7 +409,7 @@ var DirectTransport = class {
201
409
  */
202
410
  connect() {
203
411
  warnIfMultiplexPath(this._wsUrl);
204
- const ws = new WebSocket(this._wsUrl);
412
+ const ws = openWebSocket(this._wsUrl, this._headers);
205
413
  this._closed = false;
206
414
  this._ws = ws;
207
415
  ws.onopen = () => {
@@ -218,11 +426,13 @@ var DirectTransport = class {
218
426
  if (this._ws !== ws) return;
219
427
  this._ws = null;
220
428
  const discarded = this._pending.clear();
429
+ this.broker.rejectAll(`DirectTransport: the socket to ${this._wsUrl} closed before the broker answered`);
221
430
  const error = this._closeError(event, discarded);
222
431
  if (error) this.onError?.(new Error(error));
223
432
  this.onClose?.();
224
433
  };
225
434
  ws.onmessage = (event) => {
435
+ if (this.broker.handleIncoming(event.data)) return;
226
436
  this.onMessage?.(event.data);
227
437
  };
228
438
  }
@@ -287,6 +497,9 @@ var MultiplexSocket = class _MultiplexSocket {
287
497
  /** Per-URL cache so all transports targeting the same tunnel share one socket. */
288
498
  static _instances = /* @__PURE__ */ new Map();
289
499
  _wsUrl;
500
+ /** Cache key: the URL, and the handshake headers, since two secrets are two identities and need two sockets. */
501
+ _key;
502
+ _headers;
290
503
  _transports = /* @__PURE__ */ new Map();
291
504
  /** Aggregate opt-in per slot, absent when the caller did not express one. */
292
505
  _aggregates = /* @__PURE__ */ new Map();
@@ -302,16 +515,19 @@ var MultiplexSocket = class _MultiplexSocket {
302
515
  _pathWarned = false;
303
516
  /** Throttles the "wrote to a closed tunnel" line, which repeats per frame. */
304
517
  _afterStopNotice = new ThrottledNotice();
305
- constructor(wsUrl) {
518
+ constructor(wsUrl, key, headers) {
306
519
  this._wsUrl = wsUrl;
520
+ this._key = key;
521
+ this._headers = headers;
307
522
  this._pending = new PendingFrames(`MultiplexSocket ${wsUrl}`);
308
523
  }
309
- /** Returns (or creates) the singleton socket for a given tunnel URL. */
310
- static getOrCreate(wsUrl) {
311
- let instance = _MultiplexSocket._instances.get(wsUrl);
524
+ /** Returns (or creates) the singleton socket for a given tunnel URL and handshake headers. */
525
+ static getOrCreate(wsUrl, headers) {
526
+ const key = headers ? `${wsUrl}\0${JSON.stringify(Object.entries(headers).sort())}` : wsUrl;
527
+ let instance = _MultiplexSocket._instances.get(key);
312
528
  if (!instance) {
313
- instance = new _MultiplexSocket(wsUrl);
314
- _MultiplexSocket._instances.set(wsUrl, instance);
529
+ instance = new _MultiplexSocket(wsUrl, key, headers);
530
+ _MultiplexSocket._instances.set(key, instance);
315
531
  }
316
532
  return instance;
317
533
  }
@@ -342,7 +558,7 @@ var MultiplexSocket = class _MultiplexSocket {
342
558
  this._ws?.close();
343
559
  this._ws = null;
344
560
  this._dead = true;
345
- _MultiplexSocket._instances.delete(this._wsUrl);
561
+ _MultiplexSocket._instances.delete(this._key);
346
562
  }
347
563
  }
348
564
  /**
@@ -356,9 +572,9 @@ var MultiplexSocket = class _MultiplexSocket {
356
572
  _revive() {
357
573
  this._dead = false;
358
574
  this._stopped = false;
359
- const live = _MultiplexSocket._instances.get(this._wsUrl);
575
+ const live = _MultiplexSocket._instances.get(this._key);
360
576
  if (!live) {
361
- _MultiplexSocket._instances.set(this._wsUrl, this);
577
+ _MultiplexSocket._instances.set(this._key, this);
362
578
  return;
363
579
  }
364
580
  if (live !== this) {
@@ -399,13 +615,19 @@ var MultiplexSocket = class _MultiplexSocket {
399
615
  }
400
616
  this._pending.push(frame);
401
617
  }
618
+ /** Sends low-priority telemetry only while the shared link is open. */
619
+ sendTelemetry(provider, data) {
620
+ if (this._ws?.readyState !== WebSocket.OPEN) return false;
621
+ this._ws.send(encodeEnvelope(provider, data));
622
+ return true;
623
+ }
402
624
  // ── Connection lifecycle ────────────────────────────────────────────────
403
625
  _connect() {
404
626
  if (!this._pathWarned) {
405
627
  this._pathWarned = true;
406
628
  warnIfSlotScopedPath(this._wsUrl);
407
629
  }
408
- const ws = new WebSocket(this._wsUrl);
630
+ const ws = openWebSocket(this._wsUrl, this._headers);
409
631
  const notices = {
410
632
  notEnvelope: new ThrottledNotice(),
411
633
  unknownProvider: new ThrottledNotice(),
@@ -433,6 +655,7 @@ var MultiplexSocket = class _MultiplexSocket {
433
655
  if (this._ws !== ws) return;
434
656
  this._ws = null;
435
657
  for (const transport of this._transports.values()) {
658
+ transport.broker.rejectAll(`MultiplexTransport: the shared socket to ${this._wsUrl} closed before the broker answered`);
436
659
  transport.onClose?.();
437
660
  }
438
661
  if (!this._stopped) {
@@ -482,7 +705,7 @@ var MultiplexSocket = class _MultiplexSocket {
482
705
  return;
483
706
  }
484
707
  }
485
- transport.onMessage?.(envelopeFrame(envelope));
708
+ transport._receive(envelopeFrame(envelope));
486
709
  }
487
710
  _scheduleReconnect() {
488
711
  const base = 1e3;
@@ -512,10 +735,30 @@ var MultiplexTransport = class _MultiplexTransport {
512
735
  onOpen = null;
513
736
  onClose = null;
514
737
  onError = null;
738
+ /**
739
+ * The broker's own methods for this slot: declaring an authorization
740
+ * domain, asking for decisions. Its answers are taken off the socket
741
+ * before {@link onMessage} sees anything.
742
+ */
743
+ broker;
515
744
  constructor(name, socket, options) {
516
745
  this._name = name;
517
746
  this._socket = socket;
518
747
  this._aggregate = options?.aggregate;
748
+ this.broker = new BrokerClient(
749
+ (frame) => this.send(frame),
750
+ { requestTimeoutMs: options?.brokerRequestTimeoutMs },
751
+ (frame) => this._socket.sendTelemetry(this._name, frame)
752
+ );
753
+ }
754
+ /**
755
+ * One frame from the broker for this slot: the broker's answer to one of
756
+ * {@link broker}'s requests, or MCP traffic for the server.
757
+ * @internal Called by the shared socket.
758
+ */
759
+ _receive(frame) {
760
+ if (this.broker.handleIncoming(frame)) return;
761
+ this.onMessage?.(frame);
519
762
  }
520
763
  /**
521
764
  * Convenience factory: creates a {@link MultiplexTransport} backed by a
@@ -528,7 +771,7 @@ var MultiplexTransport = class _MultiplexTransport {
528
771
  * {@link DirectTransport} and is warned about on connect.
529
772
  */
530
773
  static create(name, wsUrl, options) {
531
- return new _MultiplexTransport(name, MultiplexSocket.getOrCreate(wsUrl), options);
774
+ return new _MultiplexTransport(name, MultiplexSocket.getOrCreate(wsUrl, handshakeHeaders(options)), options);
532
775
  }
533
776
  get isOpen() {
534
777
  return this._socket.isOpen;
@@ -565,6 +808,6 @@ var MultiplexTransport = class _MultiplexTransport {
565
808
  }
566
809
  };
567
810
 
568
- export { DirectTransport, MultiplexTransport, TUNNEL_REGISTER_METHOD, TunnelErrorCodes, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, encodeRegisterFrame, envelopeFrame, tunnelErrorOf };
811
+ export { AUDIT_RESULT_NOTIFICATION_METHOD, BrokerClient, BrokerRequestError, CALLER_META_KEY, DirectTransport, MultiplexTransport, TELEMETRY_NOTIFICATION_METHOD, TRACEPARENT_META_KEY, TUNNEL_REGISTER_METHOD, TunnelErrorCodes, callerReferenceOf, childTraceparent, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, encodeRegisterFrame, envelopeFrame, formatTraceparent, parseTraceparent, traceparentOf, tunnelErrorOf, withTraceparent };
569
812
  //# sourceMappingURL=index.js.map
570
813
  //# sourceMappingURL=index.js.map