@cyanmycelium/mcp-broker-provider 0.1.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/LICENSE +201 -0
- package/README.md +76 -0
- package/dist/direct.transport.d.ts +28 -0
- package/dist/direct.transport.js +55 -0
- package/dist/direct.transport.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/multiplex.transport.d.ts +81 -0
- package/dist/multiplex.transport.js +217 -0
- package/dist/multiplex.transport.js.map +1 -0
- package/dist/protocol/envelope.d.ts +85 -0
- package/dist/protocol/envelope.js +107 -0
- package/dist/protocol/envelope.js.map +1 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +2 -0
- package/dist/protocol/index.js.map +1 -0
- package/package.json +82 -0
- package/src/direct.transport.ts +66 -0
- package/src/index.ts +11 -0
- package/src/multiplex.transport.ts +248 -0
- package/src/protocol/envelope.ts +133 -0
- package/src/protocol/index.ts +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { IMessageTransport } from "@cyanmycelium/mcp-core";
|
|
2
|
+
import { decodeEnvelope, encodeEnvelope, encodeRegisterEnvelope, envelopeFrame, tunnelErrorOf } from "./protocol/index";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// MultiplexSocket — shared WebSocket singleton (internal)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages a single WebSocket connection shared by multiple {@link MultiplexTransport}
|
|
10
|
+
* instances. All traffic goes through the tunnel envelope protocol, whose
|
|
11
|
+
* definition lives in `./protocol` and is shared with the broker.
|
|
12
|
+
*
|
|
13
|
+
* Reconnection is handled centrally here — individual transports do not reconnect.
|
|
14
|
+
* Use {@link getOrCreate} to obtain a per-URL singleton.
|
|
15
|
+
*/
|
|
16
|
+
class MultiplexSocket {
|
|
17
|
+
/** Per-URL cache so all transports targeting the same tunnel share one socket. */
|
|
18
|
+
private static readonly _instances = new Map<string, MultiplexSocket>();
|
|
19
|
+
|
|
20
|
+
private readonly _wsUrl: string;
|
|
21
|
+
private readonly _transports = new Map<string, MultiplexTransport>();
|
|
22
|
+
private _ws: WebSocket | null = null;
|
|
23
|
+
private _reconnectAttempts = 0;
|
|
24
|
+
private _stopped = false;
|
|
25
|
+
|
|
26
|
+
private constructor(wsUrl: string) {
|
|
27
|
+
this._wsUrl = wsUrl;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns (or creates) the singleton socket for a given tunnel URL. */
|
|
31
|
+
static getOrCreate(wsUrl: string): MultiplexSocket {
|
|
32
|
+
let instance = MultiplexSocket._instances.get(wsUrl);
|
|
33
|
+
if (!instance) {
|
|
34
|
+
instance = new MultiplexSocket(wsUrl);
|
|
35
|
+
MultiplexSocket._instances.set(wsUrl, instance);
|
|
36
|
+
}
|
|
37
|
+
return instance;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get isOpen(): boolean {
|
|
41
|
+
return this._ws?.readyState === WebSocket.OPEN;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Registration ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
register(name: string, transport: MultiplexTransport): void {
|
|
47
|
+
this._transports.set(name, transport);
|
|
48
|
+
|
|
49
|
+
// If the shared socket is already open, announce the new provider and
|
|
50
|
+
// notify the transport immediately.
|
|
51
|
+
if (this.isOpen) {
|
|
52
|
+
this._announceProvider(name);
|
|
53
|
+
transport.onOpen?.();
|
|
54
|
+
} else if (!this._ws) {
|
|
55
|
+
// First registration — open the connection.
|
|
56
|
+
this._stopped = false;
|
|
57
|
+
this._connect();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
unregister(name: string): void {
|
|
62
|
+
this._transports.delete(name);
|
|
63
|
+
|
|
64
|
+
// Tear down the shared socket when no transports remain.
|
|
65
|
+
if (this._transports.size === 0) {
|
|
66
|
+
this._stopped = true;
|
|
67
|
+
this._ws?.close();
|
|
68
|
+
this._ws = null;
|
|
69
|
+
MultiplexSocket._instances.delete(this._wsUrl);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Claims the slot for `name` so the broker eagerly creates its provider
|
|
75
|
+
* state before any MCP client connects. Without it the broker only learns
|
|
76
|
+
* about a provider on its first real message, and a client connecting in
|
|
77
|
+
* between is told the provider is not connected.
|
|
78
|
+
*/
|
|
79
|
+
private _announceProvider(name: string): void {
|
|
80
|
+
if (this._ws?.readyState !== WebSocket.OPEN) return;
|
|
81
|
+
this._ws.send(encodeRegisterEnvelope(name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Sending ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
send(provider: string, data: string): void {
|
|
87
|
+
if (this._ws?.readyState !== WebSocket.OPEN) return;
|
|
88
|
+
this._ws.send(encodeEnvelope(provider, data));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Connection lifecycle ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
private _connect(): void {
|
|
94
|
+
const ws = new WebSocket(this._wsUrl);
|
|
95
|
+
|
|
96
|
+
// Held from construction, not from `onopen`: a transport registering
|
|
97
|
+
// while the handshake is still in flight must find this socket rather
|
|
98
|
+
// than open a second one. Readiness is decided by `readyState`, so a
|
|
99
|
+
// connecting socket is never mistaken for a usable one.
|
|
100
|
+
this._ws = ws;
|
|
101
|
+
|
|
102
|
+
ws.onopen = () => {
|
|
103
|
+
this._reconnectAttempts = 0;
|
|
104
|
+
// Announce all registered providers to the broker so it eagerly
|
|
105
|
+
// creates their slots before any MCP client connects.
|
|
106
|
+
for (const name of this._transports.keys()) {
|
|
107
|
+
this._announceProvider(name);
|
|
108
|
+
}
|
|
109
|
+
for (const transport of this._transports.values()) {
|
|
110
|
+
transport.onOpen?.();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onerror = () => {
|
|
115
|
+
for (const transport of this._transports.values()) {
|
|
116
|
+
transport.onError?.(new Error(`MultiplexSocket: WebSocket error on ${this._wsUrl}`));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
ws.onclose = () => {
|
|
121
|
+
this._ws = null;
|
|
122
|
+
for (const transport of this._transports.values()) {
|
|
123
|
+
transport.onClose?.();
|
|
124
|
+
}
|
|
125
|
+
if (!this._stopped) {
|
|
126
|
+
this._scheduleReconnect();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
ws.onmessage = (event: MessageEvent<string>) => {
|
|
131
|
+
this._routeIncoming(event.data);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private _routeIncoming(raw: string): void {
|
|
136
|
+
const envelope = decodeEnvelope(raw);
|
|
137
|
+
if (!envelope) return; // malformed — drop silently
|
|
138
|
+
|
|
139
|
+
const transport = this._transports.get(envelope.provider);
|
|
140
|
+
if (!transport) return;
|
|
141
|
+
|
|
142
|
+
// A tunnel-level refusal (a rejected slot, an unavailable provider)
|
|
143
|
+
// carries no request id. Handing it to an MCP server would get it
|
|
144
|
+
// classified as an unknown notification and dropped without a word, so
|
|
145
|
+
// surface it as a transport error instead.
|
|
146
|
+
const payload = envelope.payload as { id?: unknown } | null;
|
|
147
|
+
if (payload !== null && (payload.id === null || payload.id === undefined)) {
|
|
148
|
+
const error = tunnelErrorOf(envelope.payload);
|
|
149
|
+
if (error) {
|
|
150
|
+
transport.onError?.(new Error(`Tunnel error ${error.code} on provider "${envelope.provider}": ${error.message}`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
transport.onMessage?.(envelopeFrame(envelope));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private _scheduleReconnect(): void {
|
|
159
|
+
const base = 1_000;
|
|
160
|
+
const max = 30_000;
|
|
161
|
+
const jitter = 0.5 + Math.random() * 0.5;
|
|
162
|
+
const delay = Math.min(base * 2 ** this._reconnectAttempts, max) * jitter;
|
|
163
|
+
|
|
164
|
+
this._reconnectAttempts++;
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
if (!this._stopped) this._connect();
|
|
167
|
+
}, delay);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// MultiplexTransport — per-server transport (public)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A transport that multiplexes multiple MCP servers over a single shared
|
|
177
|
+
* WebSocket connection using the envelope protocol `{ provider, payload }`.
|
|
178
|
+
*
|
|
179
|
+
* Use the static {@link create} factory to obtain an instance:
|
|
180
|
+
* ```typescript
|
|
181
|
+
* const t1 = MultiplexTransport.create("scene-1", "ws://localhost:3000/providers");
|
|
182
|
+
* const t2 = MultiplexTransport.create("scene-2", "ws://localhost:3000/providers");
|
|
183
|
+
* // t1 and t2 share a single WebSocket under the hood.
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export class MultiplexTransport implements IMessageTransport {
|
|
187
|
+
private readonly _name: string;
|
|
188
|
+
private readonly _socket: MultiplexSocket;
|
|
189
|
+
private _registered = false;
|
|
190
|
+
|
|
191
|
+
onMessage: ((data: string) => void) | null = null;
|
|
192
|
+
onOpen: (() => void) | null = null;
|
|
193
|
+
onClose: (() => void) | null = null;
|
|
194
|
+
onError: ((error: Error) => void) | null = null;
|
|
195
|
+
|
|
196
|
+
constructor(name: string, socket: MultiplexSocket) {
|
|
197
|
+
this._name = name;
|
|
198
|
+
this._socket = socket;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Convenience factory: creates a {@link MultiplexTransport} backed by a
|
|
203
|
+
* shared {@link MultiplexSocket} for the given tunnel URL.
|
|
204
|
+
*
|
|
205
|
+
* Transports targeting the same `wsUrl` automatically share one WebSocket.
|
|
206
|
+
*/
|
|
207
|
+
static create(name: string, wsUrl: string): MultiplexTransport {
|
|
208
|
+
return new MultiplexTransport(name, MultiplexSocket.getOrCreate(wsUrl));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
get isOpen(): boolean {
|
|
212
|
+
return this._socket.isOpen;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Registers this transport with the shared socket.
|
|
217
|
+
*
|
|
218
|
+
* Safe to call multiple times — subsequent calls are no-ops.
|
|
219
|
+
*/
|
|
220
|
+
activate(): void {
|
|
221
|
+
if (!this._registered) {
|
|
222
|
+
this._registered = true;
|
|
223
|
+
this._socket.register(this._name, this);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Opens the transport, the same way every other transport does.
|
|
229
|
+
*
|
|
230
|
+
* An alias of {@link activate} so callers never have to special-case this
|
|
231
|
+
* class: an MCP server or client just calls `connect()` on whatever
|
|
232
|
+
* transport it was handed.
|
|
233
|
+
*/
|
|
234
|
+
connect(): void {
|
|
235
|
+
this.activate();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
send(data: string): void {
|
|
239
|
+
this._socket.send(this._name, data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
close(): void {
|
|
243
|
+
if (this._registered) {
|
|
244
|
+
this._registered = false;
|
|
245
|
+
this._socket.unregister(this._name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CyanMycelium tunnel envelope protocol.
|
|
3
|
+
*
|
|
4
|
+
* A multiplexed tunnel socket carries traffic for several providers at once, so
|
|
5
|
+
* every JSON-RPC message is wrapped with the name of the provider slot it
|
|
6
|
+
* belongs to. Both ends of the tunnel encode and decode with the helpers here:
|
|
7
|
+
* the client transports that publish a provider, and the broker that routes
|
|
8
|
+
* between providers and MCP clients.
|
|
9
|
+
*
|
|
10
|
+
* This module is the single definition of that wire format. It is deliberately
|
|
11
|
+
* dependency-free and isomorphic, so the browser side and the Node broker share
|
|
12
|
+
* exactly one implementation rather than two that drift apart.
|
|
13
|
+
*
|
|
14
|
+
* Wire format:
|
|
15
|
+
* ```json
|
|
16
|
+
* { "provider": "scene-1", "payload": { "jsonrpc": "2.0", "id": 1, "result": {} } }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One framed message on a multiplexed tunnel socket. */
|
|
21
|
+
export interface TunnelEnvelope {
|
|
22
|
+
/** Name of the provider slot this message belongs to. */
|
|
23
|
+
provider: string;
|
|
24
|
+
|
|
25
|
+
/** The JSON-RPC message itself, already parsed. */
|
|
26
|
+
payload: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Notification a client sends to claim a provider slot as soon as the tunnel
|
|
31
|
+
* opens, before any MCP client shows up.
|
|
32
|
+
*
|
|
33
|
+
* Without it the broker only discovers a provider name on its first real
|
|
34
|
+
* message, so an MCP client connecting in between is told the provider is not
|
|
35
|
+
* connected. It is a plain JSON-RPC notification, which any peer that does not
|
|
36
|
+
* recognize it ignores.
|
|
37
|
+
*/
|
|
38
|
+
export const TUNNEL_REGISTER_METHOD = "notifications/register";
|
|
39
|
+
|
|
40
|
+
/** JSON-RPC error codes the broker returns on the tunnel itself. */
|
|
41
|
+
export const TunnelErrorCodes = {
|
|
42
|
+
/** The slot is taken by another upstream, or the provider is not connected. */
|
|
43
|
+
ProviderUnavailable: -32000,
|
|
44
|
+
|
|
45
|
+
/** The provider's credentials do not allow publishing on this slot. */
|
|
46
|
+
RegistrationForbidden: -32001,
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export type TunnelErrorCode = (typeof TunnelErrorCodes)[keyof typeof TunnelErrorCodes];
|
|
50
|
+
|
|
51
|
+
/** A JSON-RPC error as carried inside an envelope payload. */
|
|
52
|
+
export interface TunnelError {
|
|
53
|
+
code: number;
|
|
54
|
+
message: string;
|
|
55
|
+
data?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wraps an already-serialized JSON-RPC frame for `provider`.
|
|
60
|
+
*
|
|
61
|
+
* @throws SyntaxError when `frame` is not valid JSON. Callers hold a frame they
|
|
62
|
+
* just serialized, so a failure here is a bug rather than bad input.
|
|
63
|
+
*/
|
|
64
|
+
export function encodeEnvelope(provider: string, frame: string): string {
|
|
65
|
+
return encodeEnvelopeMessage(provider, JSON.parse(frame));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wraps an already-parsed JSON-RPC message for `provider`. */
|
|
69
|
+
export function encodeEnvelopeMessage(provider: string, payload: unknown): string {
|
|
70
|
+
const envelope: TunnelEnvelope = { provider, payload };
|
|
71
|
+
return JSON.stringify(envelope);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parses a raw tunnel frame.
|
|
76
|
+
*
|
|
77
|
+
* Returns `undefined` for anything malformed rather than throwing: a tunnel
|
|
78
|
+
* socket is a public surface, and a peer sending garbage must not take the
|
|
79
|
+
* receiver down. Both ends drop such frames silently.
|
|
80
|
+
*/
|
|
81
|
+
export function decodeEnvelope(raw: string): TunnelEnvelope | undefined {
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
89
|
+
|
|
90
|
+
const { provider, payload } = parsed as Partial<TunnelEnvelope>;
|
|
91
|
+
if (typeof provider !== "string" || provider.length === 0 || payload === undefined) return undefined;
|
|
92
|
+
|
|
93
|
+
return { provider, payload };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Serializes an envelope's payload back into a plain JSON-RPC frame. */
|
|
97
|
+
export function envelopeFrame(envelope: TunnelEnvelope): string {
|
|
98
|
+
return JSON.stringify(envelope.payload);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Builds the registration notification that claims `provider`. */
|
|
102
|
+
export function encodeRegisterEnvelope(provider: string): string {
|
|
103
|
+
return encodeEnvelopeMessage(provider, { jsonrpc: "2.0", method: TUNNEL_REGISTER_METHOD });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds the error envelope the broker returns when it refuses a slot.
|
|
108
|
+
*
|
|
109
|
+
* The id is `null` because the refusal answers no particular request: it
|
|
110
|
+
* reacts to the registration itself.
|
|
111
|
+
*/
|
|
112
|
+
export function encodeErrorEnvelope(provider: string, code: TunnelErrorCode | number, message: string): string {
|
|
113
|
+
return encodeEnvelopeMessage(provider, { jsonrpc: "2.0", id: null, error: { code, message } });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reads the JSON-RPC error out of an envelope payload, when there is one.
|
|
118
|
+
*
|
|
119
|
+
* Lets the client side notice a refused registration instead of handing an
|
|
120
|
+
* `id: null` error frame to an MCP server, which would classify it as an
|
|
121
|
+
* unknown notification and drop it without a word.
|
|
122
|
+
*/
|
|
123
|
+
export function tunnelErrorOf(payload: unknown): TunnelError | undefined {
|
|
124
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
125
|
+
|
|
126
|
+
const { error } = payload as { error?: unknown };
|
|
127
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
128
|
+
|
|
129
|
+
const { code, message } = error as Partial<TunnelError>;
|
|
130
|
+
if (typeof code !== "number" || typeof message !== "string") return undefined;
|
|
131
|
+
|
|
132
|
+
return error as TunnelError;
|
|
133
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./envelope";
|