@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.
- package/SKILL.md +90 -0
- package/assets/cloudflare/worker-proxy.js +89 -0
- package/assets/cloudflare/wrangler.toml.example +10 -0
- package/package.json +66 -0
- package/references/advanced-diagnostics.md +45 -0
- package/references/mcp-tool-contract.md +184 -0
- package/references/quickstart-and-compat.md +198 -0
- package/references/vdo-ninja-p2p-data-notes.md +156 -0
- package/scripts/demo-file-workflow.js +168 -0
- package/scripts/demo-message-workflow.js +167 -0
- package/scripts/install-mcp.js +302 -0
- package/scripts/lib/bridge-session.js +2331 -0
- package/scripts/lib/bridge-utils.js +185 -0
- package/scripts/lib/fake-network-sdk.js +293 -0
- package/scripts/lib/load-vdo-sdk.js +36 -0
- package/scripts/lib/mcp-stdio-parser.js +137 -0
- package/scripts/lib/tool-profiles.js +122 -0
- package/scripts/vdo-data-bridge.js +157 -0
- package/scripts/vdo-mcp-server.js +1425 -0
- package/scripts/vdo-mcp-streamable-http.js +270 -0
- package/server.json +134 -0
|
@@ -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
|
+
};
|