@vdoninja/mcp 0.4.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,185 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ function nowISO() {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ function boolFromEnv(value, fallback) {
10
+ if (value === undefined) return fallback;
11
+ const normalized = String(value).trim().toLowerCase();
12
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
13
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
14
+ return fallback;
15
+ }
16
+
17
+ function intFromEnv(value, fallback) {
18
+ if (value === undefined || value === null) return fallback;
19
+ const parsed = Number.parseInt(String(value), 10);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
21
+ }
22
+
23
+ function intFromValue(value, fallback) {
24
+ if (value === undefined || value === null) return fallback;
25
+ const parsed = Number.parseInt(String(value), 10);
26
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
27
+ }
28
+
29
+ function nonNegativeIntFromValue(value, fallback) {
30
+ if (value === undefined || value === null) return fallback;
31
+ const parsed = Number.parseInt(String(value), 10);
32
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
33
+ }
34
+
35
+ function clampInt(value, fallback, minValue, maxValue) {
36
+ const parsed = Number.parseInt(String(value), 10);
37
+ const safe = Number.isFinite(parsed) ? parsed : fallback;
38
+ const floor = Number.isFinite(minValue) ? minValue : safe;
39
+ const ceil = Number.isFinite(maxValue) ? maxValue : safe;
40
+ return Math.min(Math.max(safe, floor), ceil);
41
+ }
42
+
43
+ function sanitizeId(value, fallback) {
44
+ const source = String(value || fallback || '').trim();
45
+ const safe = source.replace(/\W+/g, '_').replace(/^_+|_+$/g, '');
46
+ return safe || fallback || null;
47
+ }
48
+
49
+ function parsePassword(raw) {
50
+ if (raw === undefined || raw === null) return undefined;
51
+ if (raw === false) return false;
52
+ if (raw === true) return undefined;
53
+ const text = String(raw).trim().toLowerCase();
54
+ if (text === 'false') return false;
55
+ if (text === 'true' || text === '') return undefined;
56
+ return raw;
57
+ }
58
+
59
+ function generateShortId(prefix) {
60
+ const rand = crypto.randomBytes(4).toString('hex');
61
+ return prefix ? `${prefix}_${rand}` : rand;
62
+ }
63
+
64
+ function randomUUID() {
65
+ if (typeof crypto.randomUUID === 'function') {
66
+ return crypto.randomUUID();
67
+ }
68
+ return [
69
+ crypto.randomBytes(4).toString('hex'),
70
+ crypto.randomBytes(2).toString('hex'),
71
+ '4' + crypto.randomBytes(2).toString('hex').slice(1),
72
+ ((8 + Math.floor(Math.random() * 4)).toString(16) + crypto.randomBytes(2).toString('hex').slice(1)),
73
+ crypto.randomBytes(6).toString('hex')
74
+ ].join('-');
75
+ }
76
+
77
+ function sha256Hex(data) {
78
+ const hash = crypto.createHash('sha256');
79
+ hash.update(data);
80
+ return hash.digest('hex');
81
+ }
82
+
83
+ function hmacSha256Hex(secret, data) {
84
+ return crypto.createHmac('sha256', secret).update(data).digest('hex');
85
+ }
86
+
87
+ function base64UrlEncode(bufferOrText) {
88
+ const buf = Buffer.isBuffer(bufferOrText) ? bufferOrText : Buffer.from(String(bufferOrText), 'utf8');
89
+ return buf
90
+ .toString('base64')
91
+ .replace(/\+/g, '-')
92
+ .replace(/\//g, '_')
93
+ .replace(/=+$/g, '');
94
+ }
95
+
96
+ function base64UrlDecode(text) {
97
+ if (!text || typeof text !== 'string') {
98
+ throw new Error('base64url input must be a non-empty string');
99
+ }
100
+ const normalized = text.replace(/-/g, '+').replace(/_/g, '/');
101
+ const padLen = (4 - (normalized.length % 4)) % 4;
102
+ return Buffer.from(normalized + '='.repeat(padLen), 'base64');
103
+ }
104
+
105
+ function createJoinToken(payload, secret) {
106
+ if (!secret) throw new Error('secret is required');
107
+ if (!payload || typeof payload !== 'object') throw new Error('payload must be an object');
108
+ const body = base64UrlEncode(JSON.stringify(payload));
109
+ const sig = base64UrlEncode(crypto.createHmac('sha256', secret).update(body).digest());
110
+ return `${body}.${sig}`;
111
+ }
112
+
113
+ function verifyJoinToken(token, secret) {
114
+ if (!secret) throw new Error('secret is required');
115
+ if (!token || typeof token !== 'string') {
116
+ return { ok: false, error: 'token must be a non-empty string' };
117
+ }
118
+ const parts = token.split('.');
119
+ if (parts.length !== 2) {
120
+ return { ok: false, error: 'invalid token format' };
121
+ }
122
+
123
+ const [body, sig] = parts;
124
+ const expectedSig = base64UrlEncode(crypto.createHmac('sha256', secret).update(body).digest());
125
+ if (sig !== expectedSig) {
126
+ return { ok: false, error: 'invalid token signature' };
127
+ }
128
+
129
+ let payload;
130
+ try {
131
+ payload = JSON.parse(base64UrlDecode(body).toString('utf8'));
132
+ } catch (error) {
133
+ return { ok: false, error: `invalid token payload: ${error.message}` };
134
+ }
135
+
136
+ return {
137
+ ok: true,
138
+ payload
139
+ };
140
+ }
141
+
142
+ function ensureStringArray(value) {
143
+ if (value === undefined || value === null) return [];
144
+ if (Array.isArray(value)) {
145
+ return value
146
+ .map((item) => String(item || '').trim())
147
+ .filter((item) => item.length > 0);
148
+ }
149
+ if (typeof value === 'string') {
150
+ return value
151
+ .split(',')
152
+ .map((item) => item.trim())
153
+ .filter((item) => item.length > 0);
154
+ }
155
+ return [];
156
+ }
157
+
158
+ function safeJsonParse(value, fallback) {
159
+ try {
160
+ return JSON.parse(value);
161
+ } catch (error) {
162
+ return fallback;
163
+ }
164
+ }
165
+
166
+ module.exports = {
167
+ boolFromEnv,
168
+ intFromEnv,
169
+ intFromValue,
170
+ nonNegativeIntFromValue,
171
+ clampInt,
172
+ sanitizeId,
173
+ parsePassword,
174
+ nowISO,
175
+ generateShortId,
176
+ randomUUID,
177
+ sha256Hex,
178
+ hmacSha256Hex,
179
+ base64UrlEncode,
180
+ base64UrlDecode,
181
+ createJoinToken,
182
+ verifyJoinToken,
183
+ ensureStringArray,
184
+ safeJsonParse
185
+ };
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('./bridge-utils');
4
+
5
+ function clonePayload(data) {
6
+ if (data === null || data === undefined) return data;
7
+ if (Buffer.isBuffer(data)) return Buffer.from(data);
8
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
9
+ if (typeof data === 'object') {
10
+ try {
11
+ return JSON.parse(JSON.stringify(data));
12
+ } catch (error) {
13
+ return data;
14
+ }
15
+ }
16
+ return data;
17
+ }
18
+
19
+ class FakeNetworkBus {
20
+ constructor(options = {}) {
21
+ this.rooms = new Map();
22
+ this.options = options || {};
23
+ this.messageCounter = 0;
24
+ }
25
+
26
+ getRoom(name) {
27
+ if (!this.rooms.has(name)) {
28
+ this.rooms.set(name, {
29
+ members: new Set(),
30
+ publishers: new Map()
31
+ });
32
+ }
33
+ return this.rooms.get(name);
34
+ }
35
+
36
+ join(sdk, roomName) {
37
+ const room = this.getRoom(roomName);
38
+ room.members.add(sdk);
39
+ }
40
+
41
+ announce(sdk) {
42
+ const room = this.getRoom(sdk.room);
43
+ room.publishers.set(sdk.streamID, sdk);
44
+ }
45
+
46
+ connectViewer(viewer, targetStreamID) {
47
+ const room = this.getRoom(viewer.room);
48
+ const publisher = room.publishers.get(targetStreamID);
49
+ if (!publisher) {
50
+ throw new Error(`target stream not found: ${targetStreamID}`);
51
+ }
52
+ this.connectPeers(viewer, publisher);
53
+ }
54
+
55
+ connectPeers(a, b) {
56
+ if (a.peers.has(b)) return;
57
+ a.peers.add(b);
58
+ b.peers.add(a);
59
+
60
+ a.emit('peerConnected', {
61
+ uuid: b.uuid,
62
+ streamID: b.streamID || null,
63
+ connection: { type: 'viewer' }
64
+ });
65
+ b.emit('peerConnected', {
66
+ uuid: a.uuid,
67
+ streamID: a.streamID || null,
68
+ connection: { type: 'publisher' }
69
+ });
70
+
71
+ a.emit('dataChannelOpen', { uuid: b.uuid, streamID: b.streamID || null });
72
+ b.emit('dataChannelOpen', { uuid: a.uuid, streamID: a.streamID || null });
73
+ }
74
+
75
+ resolveRecipients(sender, target) {
76
+ const peers = Array.from(sender.peers);
77
+ if (!target) return peers;
78
+
79
+ if (typeof target === 'string') {
80
+ return peers.filter((peer) => peer.uuid === target || (peer.streamID && peer.streamID === target));
81
+ }
82
+
83
+ if (typeof target === 'object' && target.uuid) {
84
+ return peers.filter((peer) => peer.uuid === target.uuid);
85
+ }
86
+
87
+ return peers;
88
+ }
89
+
90
+ resolveDeliveryDecision(context) {
91
+ const opts = this.options || {};
92
+ const decision = {
93
+ drop: false,
94
+ duplicate: false,
95
+ delayMs: intOrZero(opts.fixedDelayMs),
96
+ data: context.data
97
+ };
98
+
99
+ if (opts.dropEveryNth && opts.dropEveryNth > 0 && (context.messageIndex % opts.dropEveryNth === 0)) {
100
+ decision.drop = true;
101
+ }
102
+
103
+ if (opts.duplicateEveryNth && opts.duplicateEveryNth > 0 && (context.messageIndex % opts.duplicateEveryNth === 0)) {
104
+ decision.duplicate = true;
105
+ }
106
+
107
+ if (typeof opts.transformMessage === 'function') {
108
+ const transformed = opts.transformMessage({
109
+ senderUuid: context.sender.uuid,
110
+ senderStreamID: context.sender.streamID || null,
111
+ receiverUuid: context.receiver.uuid,
112
+ receiverStreamID: context.receiver.streamID || null,
113
+ data: clonePayload(context.data),
114
+ target: context.target,
115
+ messageIndex: context.messageIndex
116
+ });
117
+
118
+ if (transformed && typeof transformed === 'object') {
119
+ if (typeof transformed.drop === 'boolean') decision.drop = transformed.drop;
120
+ if (typeof transformed.duplicate === 'boolean') decision.duplicate = transformed.duplicate;
121
+ if (Number.isFinite(transformed.delayMs) && transformed.delayMs >= 0) {
122
+ decision.delayMs = transformed.delayMs;
123
+ }
124
+ if (Object.prototype.hasOwnProperty.call(transformed, 'data')) {
125
+ decision.data = transformed.data;
126
+ }
127
+ }
128
+ }
129
+
130
+ return decision;
131
+ }
132
+
133
+ deliverToPeer(sender, receiver, data, target) {
134
+ this.messageCounter += 1;
135
+ const context = {
136
+ sender,
137
+ receiver,
138
+ data,
139
+ target,
140
+ messageIndex: this.messageCounter
141
+ };
142
+
143
+ const decision = this.resolveDeliveryDecision(context);
144
+ if (decision.drop) {
145
+ return;
146
+ }
147
+
148
+ const emitPayload = () => {
149
+ receiver.emit('dataReceived', {
150
+ data: clonePayload(decision.data),
151
+ uuid: sender.uuid,
152
+ streamID: sender.streamID,
153
+ fallback: false
154
+ });
155
+ };
156
+
157
+ const delayMs = intOrZero(decision.delayMs);
158
+ if (delayMs > 0) {
159
+ setTimeout(emitPayload, delayMs);
160
+ } else {
161
+ emitPayload();
162
+ }
163
+
164
+ if (decision.duplicate) {
165
+ setTimeout(emitPayload, Math.max(1, delayMs + 1));
166
+ }
167
+ }
168
+
169
+ disconnect(sdk) {
170
+ if (sdk.room) {
171
+ const room = this.getRoom(sdk.room);
172
+ room.members.delete(sdk);
173
+ if (sdk.streamID && room.publishers.get(sdk.streamID) === sdk) {
174
+ room.publishers.delete(sdk.streamID);
175
+ }
176
+ }
177
+
178
+ for (const peer of Array.from(sdk.peers)) {
179
+ peer.peers.delete(sdk);
180
+ peer.emit('peerDisconnected', { uuid: sdk.uuid, streamID: sdk.streamID || null });
181
+ }
182
+ sdk.peers.clear();
183
+ }
184
+ }
185
+
186
+ class FakeVDONinjaSDK {
187
+ constructor(config, bus) {
188
+ this.config = config || {};
189
+ this.bus = bus;
190
+ this.uuid = randomUUID();
191
+ this.room = null;
192
+ this.streamID = null;
193
+ this.listeners = new Map();
194
+ this.connected = false;
195
+ this.peers = new Set();
196
+ }
197
+
198
+ addEventListener(eventName, handler) {
199
+ if (!this.listeners.has(eventName)) {
200
+ this.listeners.set(eventName, new Set());
201
+ }
202
+ this.listeners.get(eventName).add(handler);
203
+ }
204
+
205
+ removeEventListener(eventName, handler) {
206
+ if (!this.listeners.has(eventName)) return;
207
+ this.listeners.get(eventName).delete(handler);
208
+ }
209
+
210
+ emit(eventName, detail) {
211
+ const handlers = this.listeners.get(eventName);
212
+ if (!handlers) return;
213
+ const event = { detail: detail || null };
214
+ for (const handler of handlers) {
215
+ try {
216
+ handler(event);
217
+ } catch (error) {
218
+ // Ignore handler errors in fake mode.
219
+ }
220
+ }
221
+ }
222
+
223
+ async connect() {
224
+ this.connected = true;
225
+ this.emit('connected', {});
226
+ }
227
+
228
+ async joinRoom(options) {
229
+ this.room = options && options.room ? options.room : this.config.room;
230
+ if (!this.room) {
231
+ throw new Error('room is required');
232
+ }
233
+ this.bus.join(this, this.room);
234
+ }
235
+
236
+ async announce(options) {
237
+ if (!this.room) {
238
+ throw new Error('joinRoom required before announce');
239
+ }
240
+ this.streamID = options && options.streamID ? options.streamID : null;
241
+ if (!this.streamID) {
242
+ throw new Error('streamID is required');
243
+ }
244
+ this.bus.announce(this);
245
+ }
246
+
247
+ async view(targetStreamID) {
248
+ if (!this.room) {
249
+ throw new Error('joinRoom required before view');
250
+ }
251
+ this.bus.connectViewer(this, targetStreamID);
252
+ }
253
+
254
+ sendData(data, target) {
255
+ const recipients = this.bus.resolveRecipients(this, target);
256
+ if (!recipients.length) return false;
257
+
258
+ for (const peer of recipients) {
259
+ this.bus.deliverToPeer(this, peer, data, target);
260
+ }
261
+ return true;
262
+ }
263
+
264
+ sendPing() {
265
+ return true;
266
+ }
267
+
268
+ disconnect() {
269
+ this.bus.disconnect(this);
270
+ if (this.connected) {
271
+ this.connected = false;
272
+ this.emit('disconnected', {});
273
+ }
274
+ }
275
+ }
276
+
277
+ function intOrZero(value) {
278
+ const parsed = Number.parseInt(String(value), 10);
279
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
280
+ }
281
+
282
+ function createFakeSDKFactory(options = {}) {
283
+ const bus = new FakeNetworkBus(options);
284
+ const factory = function sdkFactory(config) {
285
+ return new FakeVDONinjaSDK(config, bus);
286
+ };
287
+ factory.bus = bus;
288
+ return factory;
289
+ }
290
+
291
+ module.exports = {
292
+ createFakeSDKFactory
293
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ function loadVDONinjaSDK() {
4
+ const candidates = [
5
+ '@vdoninja/sdk/node',
6
+ '@vdoninja/sdk',
7
+ '../../../vdoninja-sdk-node.js'
8
+ ];
9
+
10
+ let lastError = null;
11
+ for (const candidate of candidates) {
12
+ try {
13
+ // eslint-disable-next-line global-require, import/no-dynamic-require
14
+ const resolved = require(candidate);
15
+ if (resolved) return resolved;
16
+ } catch (error) {
17
+ const isMissingModule = error &&
18
+ error.code === 'MODULE_NOT_FOUND' &&
19
+ typeof error.message === 'string' &&
20
+ error.message.includes(`Cannot find module '${candidate}'`);
21
+ if (!isMissingModule) {
22
+ throw error;
23
+ }
24
+ lastError = error;
25
+ }
26
+ }
27
+
28
+ const hint = lastError && lastError.message ? ` Last error: ${lastError.message}` : '';
29
+ throw new Error(
30
+ `Unable to load VDO.Ninja SDK. Tried: ${candidates.join(', ')}.${hint}`
31
+ );
32
+ }
33
+
34
+ module.exports = {
35
+ loadVDONinjaSDK
36
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('node:events');
4
+
5
+ class McpStdioParser extends EventEmitter {
6
+ constructor(options = {}) {
7
+ super();
8
+ this.maxMessageBytes = Number.isFinite(options.maxMessageBytes) && options.maxMessageBytes > 0
9
+ ? options.maxMessageBytes
10
+ : 1024 * 1024;
11
+ this.buffer = Buffer.alloc(0);
12
+ this.mode = null;
13
+ }
14
+
15
+ push(chunk) {
16
+ if (!chunk || chunk.length === 0) return;
17
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
18
+ this.buffer = Buffer.concat([this.buffer, incoming]);
19
+
20
+ while (this.buffer.length > 0) {
21
+ this.trimLeadingEmptyLines();
22
+ if (this.buffer.length === 0) return;
23
+
24
+ if (this.looksLikeFramedHeader()) {
25
+ if (!this.processFramedMessage()) return;
26
+ } else {
27
+ if (!this.processLineMessage()) return;
28
+ }
29
+ }
30
+ }
31
+
32
+ trimLeadingEmptyLines() {
33
+ while (this.buffer.length > 0) {
34
+ const first = this.buffer[0];
35
+ if (first === 0x0a || first === 0x0d) {
36
+ this.buffer = this.buffer.slice(1);
37
+ continue;
38
+ }
39
+ break;
40
+ }
41
+ }
42
+
43
+ looksLikeFramedHeader() {
44
+ const preview = this.buffer.slice(0, Math.min(this.buffer.length, 64)).toString('utf8');
45
+ return /^content-length\s*:/i.test(preview);
46
+ }
47
+
48
+ processFramedMessage() {
49
+ const markerCRLF = this.buffer.indexOf('\r\n\r\n');
50
+ const markerLF = this.buffer.indexOf('\n\n');
51
+ let markerIndex = -1;
52
+ let markerLength = 0;
53
+
54
+ if (markerCRLF >= 0 && (markerLF < 0 || markerCRLF < markerLF)) {
55
+ markerIndex = markerCRLF;
56
+ markerLength = 4;
57
+ } else if (markerLF >= 0) {
58
+ markerIndex = markerLF;
59
+ markerLength = 2;
60
+ }
61
+
62
+ if (markerIndex < 0) return false;
63
+
64
+ const header = this.buffer.slice(0, markerIndex).toString('utf8');
65
+ const match = header.match(/content-length\s*:\s*(\d+)/i);
66
+ if (!match) {
67
+ this.emit('error', { code: 'invalid_header', message: 'missing Content-Length header' });
68
+ this.buffer = this.buffer.slice(markerIndex + markerLength);
69
+ return true;
70
+ }
71
+
72
+ const byteLength = Number.parseInt(match[1], 10);
73
+ if (!Number.isFinite(byteLength) || byteLength < 0) {
74
+ this.emit('error', { code: 'invalid_header', message: 'invalid Content-Length value' });
75
+ this.buffer = this.buffer.slice(markerIndex + markerLength);
76
+ return true;
77
+ }
78
+
79
+ if (byteLength > this.maxMessageBytes) {
80
+ this.emit('error', { code: 'message_too_large', message: `message exceeds max size ${this.maxMessageBytes}` });
81
+ this.buffer = this.buffer.slice(markerIndex + markerLength);
82
+ return true;
83
+ }
84
+
85
+ const totalLength = markerIndex + markerLength + byteLength;
86
+ if (this.buffer.length < totalLength) return false;
87
+
88
+ const payload = this.buffer.slice(markerIndex + markerLength, totalLength).toString('utf8');
89
+ this.buffer = this.buffer.slice(totalLength);
90
+ this.mode = this.mode || 'framed';
91
+ this.emitParsed(payload, 'framed');
92
+ return true;
93
+ }
94
+
95
+ processLineMessage() {
96
+ const newlineIndex = this.buffer.indexOf(0x0a);
97
+ if (newlineIndex < 0) return false;
98
+
99
+ const line = this.buffer.slice(0, newlineIndex).toString('utf8').trim();
100
+ this.buffer = this.buffer.slice(newlineIndex + 1);
101
+ if (!line) return true;
102
+
103
+ if (Buffer.byteLength(line, 'utf8') > this.maxMessageBytes) {
104
+ this.emit('error', { code: 'message_too_large', message: `line exceeds max size ${this.maxMessageBytes}` });
105
+ return true;
106
+ }
107
+
108
+ this.mode = this.mode || 'line';
109
+ this.emitParsed(line, 'line');
110
+ return true;
111
+ }
112
+
113
+ emitParsed(raw, mode) {
114
+ try {
115
+ const message = JSON.parse(raw);
116
+ this.emit('message', { message, mode });
117
+ } catch (error) {
118
+ this.emit('error', { code: 'parse_error', message: error.message, raw });
119
+ }
120
+ }
121
+ }
122
+
123
+ function writeMcpMessage(output, payload, mode) {
124
+ const text = JSON.stringify(payload);
125
+ if (mode === 'framed') {
126
+ const body = Buffer.from(text, 'utf8');
127
+ output.write(`Content-Length: ${body.length}\r\n\r\n`);
128
+ output.write(body);
129
+ return;
130
+ }
131
+ output.write(`${text}\n`);
132
+ }
133
+
134
+ module.exports = {
135
+ McpStdioParser,
136
+ writeMcpMessage
137
+ };