@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.
@@ -0,0 +1,217 @@
1
+ import { decodeEnvelope, encodeEnvelope, encodeRegisterEnvelope, envelopeFrame, tunnelErrorOf } from "./protocol/index";
2
+ // ---------------------------------------------------------------------------
3
+ // MultiplexSocket — shared WebSocket singleton (internal)
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * Manages a single WebSocket connection shared by multiple {@link MultiplexTransport}
7
+ * instances. All traffic goes through the tunnel envelope protocol, whose
8
+ * definition lives in `./protocol` and is shared with the broker.
9
+ *
10
+ * Reconnection is handled centrally here — individual transports do not reconnect.
11
+ * Use {@link getOrCreate} to obtain a per-URL singleton.
12
+ */
13
+ class MultiplexSocket {
14
+ /** Per-URL cache so all transports targeting the same tunnel share one socket. */
15
+ static _instances = new Map();
16
+ _wsUrl;
17
+ _transports = new Map();
18
+ _ws = null;
19
+ _reconnectAttempts = 0;
20
+ _stopped = false;
21
+ constructor(wsUrl) {
22
+ this._wsUrl = wsUrl;
23
+ }
24
+ /** Returns (or creates) the singleton socket for a given tunnel URL. */
25
+ static getOrCreate(wsUrl) {
26
+ let instance = MultiplexSocket._instances.get(wsUrl);
27
+ if (!instance) {
28
+ instance = new MultiplexSocket(wsUrl);
29
+ MultiplexSocket._instances.set(wsUrl, instance);
30
+ }
31
+ return instance;
32
+ }
33
+ get isOpen() {
34
+ return this._ws?.readyState === WebSocket.OPEN;
35
+ }
36
+ // ── Registration ────────────────────────────────────────────────────────
37
+ register(name, transport) {
38
+ this._transports.set(name, transport);
39
+ // If the shared socket is already open, announce the new provider and
40
+ // notify the transport immediately.
41
+ if (this.isOpen) {
42
+ this._announceProvider(name);
43
+ transport.onOpen?.();
44
+ }
45
+ else if (!this._ws) {
46
+ // First registration — open the connection.
47
+ this._stopped = false;
48
+ this._connect();
49
+ }
50
+ }
51
+ unregister(name) {
52
+ this._transports.delete(name);
53
+ // Tear down the shared socket when no transports remain.
54
+ if (this._transports.size === 0) {
55
+ this._stopped = true;
56
+ this._ws?.close();
57
+ this._ws = null;
58
+ MultiplexSocket._instances.delete(this._wsUrl);
59
+ }
60
+ }
61
+ /**
62
+ * Claims the slot for `name` so the broker eagerly creates its provider
63
+ * state before any MCP client connects. Without it the broker only learns
64
+ * about a provider on its first real message, and a client connecting in
65
+ * between is told the provider is not connected.
66
+ */
67
+ _announceProvider(name) {
68
+ if (this._ws?.readyState !== WebSocket.OPEN)
69
+ return;
70
+ this._ws.send(encodeRegisterEnvelope(name));
71
+ }
72
+ // ── Sending ─────────────────────────────────────────────────────────────
73
+ send(provider, data) {
74
+ if (this._ws?.readyState !== WebSocket.OPEN)
75
+ return;
76
+ this._ws.send(encodeEnvelope(provider, data));
77
+ }
78
+ // ── Connection lifecycle ────────────────────────────────────────────────
79
+ _connect() {
80
+ const ws = new WebSocket(this._wsUrl);
81
+ // Held from construction, not from `onopen`: a transport registering
82
+ // while the handshake is still in flight must find this socket rather
83
+ // than open a second one. Readiness is decided by `readyState`, so a
84
+ // connecting socket is never mistaken for a usable one.
85
+ this._ws = ws;
86
+ ws.onopen = () => {
87
+ this._reconnectAttempts = 0;
88
+ // Announce all registered providers to the broker so it eagerly
89
+ // creates their slots before any MCP client connects.
90
+ for (const name of this._transports.keys()) {
91
+ this._announceProvider(name);
92
+ }
93
+ for (const transport of this._transports.values()) {
94
+ transport.onOpen?.();
95
+ }
96
+ };
97
+ ws.onerror = () => {
98
+ for (const transport of this._transports.values()) {
99
+ transport.onError?.(new Error(`MultiplexSocket: WebSocket error on ${this._wsUrl}`));
100
+ }
101
+ };
102
+ ws.onclose = () => {
103
+ this._ws = null;
104
+ for (const transport of this._transports.values()) {
105
+ transport.onClose?.();
106
+ }
107
+ if (!this._stopped) {
108
+ this._scheduleReconnect();
109
+ }
110
+ };
111
+ ws.onmessage = (event) => {
112
+ this._routeIncoming(event.data);
113
+ };
114
+ }
115
+ _routeIncoming(raw) {
116
+ const envelope = decodeEnvelope(raw);
117
+ if (!envelope)
118
+ return; // malformed — drop silently
119
+ const transport = this._transports.get(envelope.provider);
120
+ if (!transport)
121
+ return;
122
+ // A tunnel-level refusal (a rejected slot, an unavailable provider)
123
+ // carries no request id. Handing it to an MCP server would get it
124
+ // classified as an unknown notification and dropped without a word, so
125
+ // surface it as a transport error instead.
126
+ const payload = envelope.payload;
127
+ if (payload !== null && (payload.id === null || payload.id === undefined)) {
128
+ const error = tunnelErrorOf(envelope.payload);
129
+ if (error) {
130
+ transport.onError?.(new Error(`Tunnel error ${error.code} on provider "${envelope.provider}": ${error.message}`));
131
+ return;
132
+ }
133
+ }
134
+ transport.onMessage?.(envelopeFrame(envelope));
135
+ }
136
+ _scheduleReconnect() {
137
+ const base = 1_000;
138
+ const max = 30_000;
139
+ const jitter = 0.5 + Math.random() * 0.5;
140
+ const delay = Math.min(base * 2 ** this._reconnectAttempts, max) * jitter;
141
+ this._reconnectAttempts++;
142
+ setTimeout(() => {
143
+ if (!this._stopped)
144
+ this._connect();
145
+ }, delay);
146
+ }
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // MultiplexTransport — per-server transport (public)
150
+ // ---------------------------------------------------------------------------
151
+ /**
152
+ * A transport that multiplexes multiple MCP servers over a single shared
153
+ * WebSocket connection using the envelope protocol `{ provider, payload }`.
154
+ *
155
+ * Use the static {@link create} factory to obtain an instance:
156
+ * ```typescript
157
+ * const t1 = MultiplexTransport.create("scene-1", "ws://localhost:3000/providers");
158
+ * const t2 = MultiplexTransport.create("scene-2", "ws://localhost:3000/providers");
159
+ * // t1 and t2 share a single WebSocket under the hood.
160
+ * ```
161
+ */
162
+ export class MultiplexTransport {
163
+ _name;
164
+ _socket;
165
+ _registered = false;
166
+ onMessage = null;
167
+ onOpen = null;
168
+ onClose = null;
169
+ onError = null;
170
+ constructor(name, socket) {
171
+ this._name = name;
172
+ this._socket = socket;
173
+ }
174
+ /**
175
+ * Convenience factory: creates a {@link MultiplexTransport} backed by a
176
+ * shared {@link MultiplexSocket} for the given tunnel URL.
177
+ *
178
+ * Transports targeting the same `wsUrl` automatically share one WebSocket.
179
+ */
180
+ static create(name, wsUrl) {
181
+ return new MultiplexTransport(name, MultiplexSocket.getOrCreate(wsUrl));
182
+ }
183
+ get isOpen() {
184
+ return this._socket.isOpen;
185
+ }
186
+ /**
187
+ * Registers this transport with the shared socket.
188
+ *
189
+ * Safe to call multiple times — subsequent calls are no-ops.
190
+ */
191
+ activate() {
192
+ if (!this._registered) {
193
+ this._registered = true;
194
+ this._socket.register(this._name, this);
195
+ }
196
+ }
197
+ /**
198
+ * Opens the transport, the same way every other transport does.
199
+ *
200
+ * An alias of {@link activate} so callers never have to special-case this
201
+ * class: an MCP server or client just calls `connect()` on whatever
202
+ * transport it was handed.
203
+ */
204
+ connect() {
205
+ this.activate();
206
+ }
207
+ send(data) {
208
+ this._socket.send(this._name, data);
209
+ }
210
+ close() {
211
+ if (this._registered) {
212
+ this._registered = false;
213
+ this._socket.unregister(this._name);
214
+ }
215
+ }
216
+ }
217
+ //# sourceMappingURL=multiplex.transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multiplex.transport.js","sourceRoot":"","sources":["../src/multiplex.transport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,85 @@
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
+ /** One framed message on a multiplexed tunnel socket. */
20
+ export interface TunnelEnvelope {
21
+ /** Name of the provider slot this message belongs to. */
22
+ provider: string;
23
+ /** The JSON-RPC message itself, already parsed. */
24
+ payload: unknown;
25
+ }
26
+ /**
27
+ * Notification a client sends to claim a provider slot as soon as the tunnel
28
+ * opens, before any MCP client shows up.
29
+ *
30
+ * Without it the broker only discovers a provider name on its first real
31
+ * message, so an MCP client connecting in between is told the provider is not
32
+ * connected. It is a plain JSON-RPC notification, which any peer that does not
33
+ * recognize it ignores.
34
+ */
35
+ export declare const TUNNEL_REGISTER_METHOD = "notifications/register";
36
+ /** JSON-RPC error codes the broker returns on the tunnel itself. */
37
+ export declare const TunnelErrorCodes: {
38
+ /** The slot is taken by another upstream, or the provider is not connected. */
39
+ readonly ProviderUnavailable: -32000;
40
+ /** The provider's credentials do not allow publishing on this slot. */
41
+ readonly RegistrationForbidden: -32001;
42
+ };
43
+ export type TunnelErrorCode = (typeof TunnelErrorCodes)[keyof typeof TunnelErrorCodes];
44
+ /** A JSON-RPC error as carried inside an envelope payload. */
45
+ export interface TunnelError {
46
+ code: number;
47
+ message: string;
48
+ data?: unknown;
49
+ }
50
+ /**
51
+ * Wraps an already-serialized JSON-RPC frame for `provider`.
52
+ *
53
+ * @throws SyntaxError when `frame` is not valid JSON. Callers hold a frame they
54
+ * just serialized, so a failure here is a bug rather than bad input.
55
+ */
56
+ export declare function encodeEnvelope(provider: string, frame: string): string;
57
+ /** Wraps an already-parsed JSON-RPC message for `provider`. */
58
+ export declare function encodeEnvelopeMessage(provider: string, payload: unknown): string;
59
+ /**
60
+ * Parses a raw tunnel frame.
61
+ *
62
+ * Returns `undefined` for anything malformed rather than throwing: a tunnel
63
+ * socket is a public surface, and a peer sending garbage must not take the
64
+ * receiver down. Both ends drop such frames silently.
65
+ */
66
+ export declare function decodeEnvelope(raw: string): TunnelEnvelope | undefined;
67
+ /** Serializes an envelope's payload back into a plain JSON-RPC frame. */
68
+ export declare function envelopeFrame(envelope: TunnelEnvelope): string;
69
+ /** Builds the registration notification that claims `provider`. */
70
+ export declare function encodeRegisterEnvelope(provider: string): string;
71
+ /**
72
+ * Builds the error envelope the broker returns when it refuses a slot.
73
+ *
74
+ * The id is `null` because the refusal answers no particular request: it
75
+ * reacts to the registration itself.
76
+ */
77
+ export declare function encodeErrorEnvelope(provider: string, code: TunnelErrorCode | number, message: string): string;
78
+ /**
79
+ * Reads the JSON-RPC error out of an envelope payload, when there is one.
80
+ *
81
+ * Lets the client side notice a refused registration instead of handing an
82
+ * `id: null` error frame to an MCP server, which would classify it as an
83
+ * unknown notification and drop it without a word.
84
+ */
85
+ export declare function tunnelErrorOf(payload: unknown): TunnelError | undefined;
@@ -0,0 +1,107 @@
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
+ * Notification a client sends to claim a provider slot as soon as the tunnel
21
+ * opens, before any MCP client shows up.
22
+ *
23
+ * Without it the broker only discovers a provider name on its first real
24
+ * message, so an MCP client connecting in between is told the provider is not
25
+ * connected. It is a plain JSON-RPC notification, which any peer that does not
26
+ * recognize it ignores.
27
+ */
28
+ export const TUNNEL_REGISTER_METHOD = "notifications/register";
29
+ /** JSON-RPC error codes the broker returns on the tunnel itself. */
30
+ export const TunnelErrorCodes = {
31
+ /** The slot is taken by another upstream, or the provider is not connected. */
32
+ ProviderUnavailable: -32000,
33
+ /** The provider's credentials do not allow publishing on this slot. */
34
+ RegistrationForbidden: -32001,
35
+ };
36
+ /**
37
+ * Wraps an already-serialized JSON-RPC frame for `provider`.
38
+ *
39
+ * @throws SyntaxError when `frame` is not valid JSON. Callers hold a frame they
40
+ * just serialized, so a failure here is a bug rather than bad input.
41
+ */
42
+ export function encodeEnvelope(provider, frame) {
43
+ return encodeEnvelopeMessage(provider, JSON.parse(frame));
44
+ }
45
+ /** Wraps an already-parsed JSON-RPC message for `provider`. */
46
+ export function encodeEnvelopeMessage(provider, payload) {
47
+ const envelope = { provider, payload };
48
+ return JSON.stringify(envelope);
49
+ }
50
+ /**
51
+ * Parses a raw tunnel frame.
52
+ *
53
+ * Returns `undefined` for anything malformed rather than throwing: a tunnel
54
+ * socket is a public surface, and a peer sending garbage must not take the
55
+ * receiver down. Both ends drop such frames silently.
56
+ */
57
+ export function decodeEnvelope(raw) {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
66
+ return undefined;
67
+ const { provider, payload } = parsed;
68
+ if (typeof provider !== "string" || provider.length === 0 || payload === undefined)
69
+ return undefined;
70
+ return { provider, payload };
71
+ }
72
+ /** Serializes an envelope's payload back into a plain JSON-RPC frame. */
73
+ export function envelopeFrame(envelope) {
74
+ return JSON.stringify(envelope.payload);
75
+ }
76
+ /** Builds the registration notification that claims `provider`. */
77
+ export function encodeRegisterEnvelope(provider) {
78
+ return encodeEnvelopeMessage(provider, { jsonrpc: "2.0", method: TUNNEL_REGISTER_METHOD });
79
+ }
80
+ /**
81
+ * Builds the error envelope the broker returns when it refuses a slot.
82
+ *
83
+ * The id is `null` because the refusal answers no particular request: it
84
+ * reacts to the registration itself.
85
+ */
86
+ export function encodeErrorEnvelope(provider, code, message) {
87
+ return encodeEnvelopeMessage(provider, { jsonrpc: "2.0", id: null, error: { code, message } });
88
+ }
89
+ /**
90
+ * Reads the JSON-RPC error out of an envelope payload, when there is one.
91
+ *
92
+ * Lets the client side notice a refused registration instead of handing an
93
+ * `id: null` error frame to an MCP server, which would classify it as an
94
+ * unknown notification and drop it without a word.
95
+ */
96
+ export function tunnelErrorOf(payload) {
97
+ if (typeof payload !== "object" || payload === null)
98
+ return undefined;
99
+ const { error } = payload;
100
+ if (typeof error !== "object" || error === null)
101
+ return undefined;
102
+ const { code, message } = error;
103
+ if (typeof code !== "number" || typeof message !== "string")
104
+ return undefined;
105
+ return error;
106
+ }
107
+ //# sourceMappingURL=envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.js","sourceRoot":"","sources":["../../src/protocol/envelope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAWH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,wBAAwB,CAAC;AAE/D,oEAAoE;AACpE,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC5B,+EAA+E;IAC/E,mBAAmB,EAAE,CAAC,KAAK;IAE3B,uEAAuE;IACvE,qBAAqB,EAAE,CAAC,KAAK;CACvB,CAAC;AAWX;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB,EAAE,KAAa;IAC1D,OAAO,qBAAqB,CAAC,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,qBAAqB,CAAC,QAAgB,EAAE,OAAgB;IACpE,MAAM,QAAQ,GAAmB,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;IACvD,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW;IACtC,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACD,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,SAAS,CAAC;IACrB,CAAC;IACD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,SAAS,CAAC;IAE7F,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAiC,CAAC;IAChE,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAErG,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;AACjC,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,aAAa,CAAC,QAAwB;IAClD,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;AAC5C,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,sBAAsB,CAAC,QAAgB;IACnD,OAAO,qBAAqB,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC,CAAC;AAC/F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,QAAgB,EAAE,IAA8B,EAAE,OAAe;IACjG,OAAO,qBAAqB,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;AACnG,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC1C,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAEtE,MAAM,EAAE,KAAK,EAAE,GAAG,OAA8B,CAAC;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAElE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,KAA6B,CAAC;IACxD,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAE9E,OAAO,KAAoB,CAAC;AAChC,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./envelope";
@@ -0,0 +1,2 @@
1
+ export * from "./envelope";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/protocol/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC"}
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@cyanmycelium/mcp-broker-provider",
3
+ "version": "0.1.0",
4
+ "description": "Provider side of the CyanMycelium MCP broker tunnel: publish an MCP server to a broker slot over the shared envelope protocol.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "author": "Guillaume Pelletier (CyanMycelium)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pandaGaume/mcp-broker.git",
11
+ "directory": "node/packages/provider"
12
+ },
13
+ "homepage": "https://github.com/pandaGaume/mcp-broker#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/pandaGaume/mcp-broker/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "broker",
21
+ "provider",
22
+ "tunnel",
23
+ "websocket",
24
+ "transport",
25
+ "cyanmycelium"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20.11.0"
29
+ },
30
+ "sideEffects": false,
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "LICENSE",
35
+ "README.md"
36
+ ],
37
+ "main": "./dist/index.js",
38
+ "module": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "exports": {
41
+ ".": {
42
+ "types": "./dist/index.d.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./protocol": {
47
+ "types": "./dist/protocol/index.d.ts",
48
+ "import": "./dist/protocol/index.js",
49
+ "default": "./dist/protocol/index.js"
50
+ }
51
+ },
52
+ "scripts": {
53
+ "build": "npm run clean && npm run compile",
54
+ "clean": "rimraf dist tsconfig.build.tsbuildinfo",
55
+ "compile": "tsc -b tsconfig.build.json",
56
+ "watch": "tsc -b tsconfig.build.json -w",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
60
+ "lint:fix": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" --fix",
61
+ "format": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
62
+ "format:fix": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
63
+ "prepublishOnly": "npm run lint && npm run build && npm test"
64
+ },
65
+ "peerDependencies": {
66
+ "@cyanmycelium/mcp-core": "^0.4.0"
67
+ },
68
+ "devDependencies": {
69
+ "@cyanmycelium/mcp-core": "^0.4.0",
70
+ "@types/node": "^20.11.0",
71
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
72
+ "@typescript-eslint/parser": "^7.0.0",
73
+ "eslint": "^8.57.0",
74
+ "eslint-config-prettier": "^9.1.0",
75
+ "eslint-plugin-prettier": "^5.1.0",
76
+ "prettier": "^3.2.0",
77
+ "rimraf": "~6.0.1",
78
+ "tslib": "^2.8.1",
79
+ "typescript": "^5.4.0",
80
+ "vitest": "^4.1.9"
81
+ }
82
+ }
@@ -0,0 +1,66 @@
1
+ import type { IMessageTransport } from "@cyanmycelium/mcp-core";
2
+
3
+ /**
4
+ * 1:1 WebSocket transport — wraps a single `WebSocket` connection to a broker
5
+ * provider slot, typically `ws://<broker>/provider/<name>`.
6
+ *
7
+ * One server owns one socket. When an application publishes several servers
8
+ * through the same broker, prefer {@link MultiplexTransport}, which shares a
9
+ * single socket between them.
10
+ *
11
+ * Call {@link connect} after setting the event callbacks to open the socket.
12
+ */
13
+ export class DirectTransport implements IMessageTransport {
14
+ private readonly _wsUrl: string;
15
+ private _ws: WebSocket | null = null;
16
+
17
+ onMessage: ((data: string) => void) | null = null;
18
+ onOpen: (() => void) | null = null;
19
+ onClose: (() => void) | null = null;
20
+ onError: ((error: Error) => void) | null = null;
21
+
22
+ constructor(wsUrl: string) {
23
+ this._wsUrl = wsUrl;
24
+ }
25
+
26
+ get isOpen(): boolean {
27
+ return this._ws?.readyState === WebSocket.OPEN;
28
+ }
29
+
30
+ /**
31
+ * Opens the WebSocket connection and wires its events to the transport
32
+ * callbacks. Must be called after assigning `onOpen` / `onMessage` / etc.
33
+ */
34
+ connect(): void {
35
+ const ws = new WebSocket(this._wsUrl);
36
+
37
+ ws.onopen = () => {
38
+ this._ws = ws;
39
+ this.onOpen?.();
40
+ };
41
+
42
+ ws.onerror = () => {
43
+ this.onError?.(new Error(`DirectTransport: WebSocket error on ${this._wsUrl}`));
44
+ };
45
+
46
+ ws.onclose = () => {
47
+ this._ws = null;
48
+ this.onClose?.();
49
+ };
50
+
51
+ ws.onmessage = (event: MessageEvent<string>) => {
52
+ this.onMessage?.(event.data);
53
+ };
54
+ }
55
+
56
+ send(data: string): void {
57
+ if (this._ws?.readyState === WebSocket.OPEN) {
58
+ this._ws.send(data);
59
+ }
60
+ }
61
+
62
+ close(): void {
63
+ this._ws?.close();
64
+ this._ws = null;
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Provider side of the CyanMycelium MCP broker tunnel: what an application uses
3
+ * to publish its MCP server to a broker slot.
4
+ *
5
+ * The tunnel envelope protocol is also published on its own entry point,
6
+ * `@cyanmycelium/mcp-broker-provider/protocol`, which the broker imports so both
7
+ * ends of the tunnel share one definition of the wire format.
8
+ */
9
+ export * from "./protocol/index";
10
+ export { DirectTransport } from "./direct.transport";
11
+ export { MultiplexTransport } from "./multiplex.transport";