@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,1425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { BridgeSession } = require('./lib/bridge-session');
|
|
5
|
+
const { McpStdioParser, writeMcpMessage } = require('./lib/mcp-stdio-parser');
|
|
6
|
+
const {
|
|
7
|
+
DEFAULT_TOOL_PROFILE,
|
|
8
|
+
PROFILE_NAMES,
|
|
9
|
+
selectToolsForProfile
|
|
10
|
+
} = require('./lib/tool-profiles');
|
|
11
|
+
const {
|
|
12
|
+
intFromEnv,
|
|
13
|
+
intFromValue,
|
|
14
|
+
parsePassword,
|
|
15
|
+
sanitizeId,
|
|
16
|
+
generateShortId,
|
|
17
|
+
randomUUID,
|
|
18
|
+
nowISO,
|
|
19
|
+
verifyJoinToken,
|
|
20
|
+
ensureStringArray,
|
|
21
|
+
nonNegativeIntFromValue
|
|
22
|
+
} = require('./lib/bridge-utils');
|
|
23
|
+
|
|
24
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2024-11-05'];
|
|
25
|
+
const SERVER_INFO = {
|
|
26
|
+
name: 'vdo-ninja-mcp',
|
|
27
|
+
version: '0.4.0'
|
|
28
|
+
};
|
|
29
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
function buildTools() {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: 'vdo_connect',
|
|
35
|
+
description: 'Start a VDO.Ninja data-channel session and optionally auto-view a target stream.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
room: { type: 'string', description: 'Room name (alphanumeric/underscore recommended).' },
|
|
40
|
+
stream_id: { type: 'string', description: 'Local stream id. Randomized when omitted.' },
|
|
41
|
+
target_stream_id: { type: 'string', description: 'Optional remote stream id to auto-view.' },
|
|
42
|
+
host: { type: 'string', description: 'WebSocket endpoint. Defaults to wss://wss.vdo.ninja.' },
|
|
43
|
+
password: {
|
|
44
|
+
oneOf: [{ type: 'string' }, { type: 'boolean' }],
|
|
45
|
+
description: 'Set false to disable encryption; omitted uses SDK default.'
|
|
46
|
+
},
|
|
47
|
+
force_turn: { type: 'boolean', description: 'Force TURN relay mode.' },
|
|
48
|
+
heartbeat_ms: { type: 'integer', minimum: 1 },
|
|
49
|
+
reconnect_ms: { type: 'integer', minimum: 1 },
|
|
50
|
+
max_reconnect_ms: { type: 'integer', minimum: 1 },
|
|
51
|
+
label: { type: 'string', description: 'Optional publisher label.' },
|
|
52
|
+
debug: { type: 'boolean' },
|
|
53
|
+
join_token: { type: 'string', description: 'Optional signed join token.' },
|
|
54
|
+
join_token_secret: { type: 'string', description: 'Optional secret for validating/generating join tokens.' },
|
|
55
|
+
join_token_ttl_ms: { type: 'integer', minimum: 1000 },
|
|
56
|
+
enforce_join_token: { type: 'boolean', description: 'Reject peers without valid join token.' },
|
|
57
|
+
allow_peer_stream_ids: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
description: 'Optional stream_id allowlist for room/file actions.'
|
|
61
|
+
},
|
|
62
|
+
file_chunk_bytes: { type: 'integer', minimum: 1024 },
|
|
63
|
+
file_max_bytes: { type: 'integer', minimum: 1 },
|
|
64
|
+
file_ack_timeout_ms: { type: 'integer', minimum: 1 },
|
|
65
|
+
file_max_retries: { type: 'integer', minimum: 0 },
|
|
66
|
+
require_session_mac: { type: 'boolean', description: 'Require per-peer session MAC on file/control envelopes.' },
|
|
67
|
+
file_spool_dir: { type: 'string', description: 'Disk directory for large-transfer spooling.' },
|
|
68
|
+
file_spool_threshold_bytes: { type: 'integer', minimum: 1, description: 'Spool incoming transfers to disk at/above this size.' },
|
|
69
|
+
keep_spool_files: { type: 'boolean', description: 'Keep spooled files on cleanup/prune.' },
|
|
70
|
+
state_max_keys: { type: 'integer', minimum: 1 },
|
|
71
|
+
state_max_snapshot_entries: { type: 'integer', minimum: 1 }
|
|
72
|
+
},
|
|
73
|
+
required: ['room'],
|
|
74
|
+
additionalProperties: false
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'vdo_send',
|
|
79
|
+
description: 'Send one payload over an active session data channel.',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
session_id: { type: 'string' },
|
|
84
|
+
data: {},
|
|
85
|
+
target: {
|
|
86
|
+
oneOf: [{ type: 'string' }, { type: 'object' }],
|
|
87
|
+
description: 'Optional uuid or target object.'
|
|
88
|
+
},
|
|
89
|
+
uuid: { type: 'string', description: 'Optional shorthand target uuid.' }
|
|
90
|
+
},
|
|
91
|
+
required: ['session_id', 'data'],
|
|
92
|
+
additionalProperties: false
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'vdo_receive',
|
|
97
|
+
description: 'Poll queued bridge events including data_received and lifecycle events.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
session_id: { type: 'string' },
|
|
102
|
+
max_events: { type: 'integer', minimum: 1 },
|
|
103
|
+
wait_ms: { type: 'integer', minimum: 0, maximum: 30000 }
|
|
104
|
+
},
|
|
105
|
+
required: ['session_id'],
|
|
106
|
+
additionalProperties: false
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'vdo_status',
|
|
111
|
+
description: 'Read live status for a session.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
session_id: { type: 'string' }
|
|
116
|
+
},
|
|
117
|
+
required: ['session_id'],
|
|
118
|
+
additionalProperties: false
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'vdo_disconnect',
|
|
123
|
+
description: 'Stop and remove a session.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
session_id: { type: 'string' }
|
|
128
|
+
},
|
|
129
|
+
required: ['session_id'],
|
|
130
|
+
additionalProperties: false
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'vdo_list_sessions',
|
|
135
|
+
description: 'List all active sessions.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {},
|
|
139
|
+
additionalProperties: false
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'vdo_capabilities',
|
|
144
|
+
description: 'Return machine-readable server capabilities, tool profile scope, and transport constraints.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {},
|
|
148
|
+
additionalProperties: false
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'vdo_sync_peers',
|
|
153
|
+
description: 'List sync/handshake metadata for known peers in a session.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: {
|
|
157
|
+
session_id: { type: 'string' }
|
|
158
|
+
},
|
|
159
|
+
required: ['session_id'],
|
|
160
|
+
additionalProperties: false
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'vdo_sync_announce',
|
|
165
|
+
description: 'Send an immediate sync hello broadcast (or to one target).',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
session_id: { type: 'string' },
|
|
170
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
171
|
+
uuid: { type: 'string' }
|
|
172
|
+
},
|
|
173
|
+
required: ['session_id'],
|
|
174
|
+
additionalProperties: false
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'vdo_file_send',
|
|
179
|
+
description: 'Send a file payload with chunking, integrity checks, retries, and resume support.',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
session_id: { type: 'string' },
|
|
184
|
+
data_base64: { type: 'string', description: 'Entire file payload encoded as base64.' },
|
|
185
|
+
name: { type: 'string' },
|
|
186
|
+
mime: { type: 'string' },
|
|
187
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
188
|
+
uuid: { type: 'string' },
|
|
189
|
+
transfer_id: { type: 'string' },
|
|
190
|
+
chunk_bytes: { type: 'integer', minimum: 1024 },
|
|
191
|
+
timeout_ms: { type: 'integer', minimum: 1 },
|
|
192
|
+
ack_timeout_ms: { type: 'integer', minimum: 1 },
|
|
193
|
+
max_retries: { type: 'integer', minimum: 0 }
|
|
194
|
+
},
|
|
195
|
+
required: ['session_id', 'data_base64'],
|
|
196
|
+
additionalProperties: false
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'vdo_file_send_path',
|
|
201
|
+
description: 'Send a file directly from local path with chunking/retry/resume support.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
session_id: { type: 'string' },
|
|
206
|
+
file_path: { type: 'string' },
|
|
207
|
+
name: { type: 'string' },
|
|
208
|
+
mime: { type: 'string' },
|
|
209
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
210
|
+
uuid: { type: 'string' },
|
|
211
|
+
transfer_id: { type: 'string' },
|
|
212
|
+
chunk_bytes: { type: 'integer', minimum: 1024 },
|
|
213
|
+
timeout_ms: { type: 'integer', minimum: 1 },
|
|
214
|
+
ack_timeout_ms: { type: 'integer', minimum: 1 },
|
|
215
|
+
max_retries: { type: 'integer', minimum: 0 }
|
|
216
|
+
},
|
|
217
|
+
required: ['session_id', 'file_path'],
|
|
218
|
+
additionalProperties: false
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'vdo_file_resume',
|
|
223
|
+
description: 'Resume an interrupted outgoing file transfer by transfer_id.',
|
|
224
|
+
inputSchema: {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: {
|
|
227
|
+
session_id: { type: 'string' },
|
|
228
|
+
transfer_id: { type: 'string' },
|
|
229
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
230
|
+
start_seq: { type: 'integer', minimum: 0 },
|
|
231
|
+
ack_timeout_ms: { type: 'integer', minimum: 1 },
|
|
232
|
+
max_retries: { type: 'integer', minimum: 0 }
|
|
233
|
+
},
|
|
234
|
+
required: ['session_id', 'transfer_id'],
|
|
235
|
+
additionalProperties: false
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'vdo_file_transfers',
|
|
240
|
+
description: 'List incoming/outgoing file transfer states.',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
session_id: { type: 'string' },
|
|
245
|
+
direction: {
|
|
246
|
+
type: 'string',
|
|
247
|
+
enum: ['incoming', 'outgoing', 'all']
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
required: ['session_id'],
|
|
251
|
+
additionalProperties: false
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'vdo_file_receive',
|
|
256
|
+
description: 'Read a completed incoming transfer payload.',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: {
|
|
260
|
+
session_id: { type: 'string' },
|
|
261
|
+
transfer_id: { type: 'string' },
|
|
262
|
+
encoding: {
|
|
263
|
+
type: 'string',
|
|
264
|
+
enum: ['base64', 'utf8', 'json']
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
required: ['session_id', 'transfer_id'],
|
|
268
|
+
additionalProperties: false
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'vdo_file_save',
|
|
273
|
+
description: 'Write a completed incoming transfer payload to local disk path.',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
session_id: { type: 'string' },
|
|
278
|
+
transfer_id: { type: 'string' },
|
|
279
|
+
output_path: { type: 'string' },
|
|
280
|
+
overwrite: { type: 'boolean' }
|
|
281
|
+
},
|
|
282
|
+
required: ['session_id', 'transfer_id', 'output_path'],
|
|
283
|
+
additionalProperties: false
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'vdo_state_set',
|
|
288
|
+
description: 'Set/update a room state key (CRDT-lite last-writer tuple by clock+actor).',
|
|
289
|
+
inputSchema: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
properties: {
|
|
292
|
+
session_id: { type: 'string' },
|
|
293
|
+
key: { type: 'string' },
|
|
294
|
+
value: {},
|
|
295
|
+
broadcast: { type: 'boolean' },
|
|
296
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
297
|
+
uuid: { type: 'string' }
|
|
298
|
+
},
|
|
299
|
+
required: ['session_id', 'key'],
|
|
300
|
+
additionalProperties: false
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'vdo_state_get',
|
|
305
|
+
description: 'Read room state key or full snapshot.',
|
|
306
|
+
inputSchema: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {
|
|
309
|
+
session_id: { type: 'string' },
|
|
310
|
+
key: { type: 'string' },
|
|
311
|
+
include_meta: { type: 'boolean' }
|
|
312
|
+
},
|
|
313
|
+
required: ['session_id'],
|
|
314
|
+
additionalProperties: false
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'vdo_state_sync',
|
|
319
|
+
description: 'Request or send full room-state snapshot.',
|
|
320
|
+
inputSchema: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
session_id: { type: 'string' },
|
|
324
|
+
mode: { type: 'string', enum: ['request', 'send'] },
|
|
325
|
+
target: { oneOf: [{ type: 'string' }, { type: 'object' }] },
|
|
326
|
+
uuid: { type: 'string' }
|
|
327
|
+
},
|
|
328
|
+
required: ['session_id'],
|
|
329
|
+
additionalProperties: false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function makeToolResult(payload, isError) {
|
|
336
|
+
return {
|
|
337
|
+
content: [
|
|
338
|
+
{
|
|
339
|
+
type: 'text',
|
|
340
|
+
text: JSON.stringify(payload)
|
|
341
|
+
}
|
|
342
|
+
],
|
|
343
|
+
structuredContent: payload,
|
|
344
|
+
isError: !!isError
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function toBool(value, fallback) {
|
|
349
|
+
if (value === undefined || value === null) return fallback;
|
|
350
|
+
if (typeof value === 'boolean') return value;
|
|
351
|
+
const text = String(value).trim().toLowerCase();
|
|
352
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(text)) return true;
|
|
353
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(text)) return false;
|
|
354
|
+
return fallback;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function toPositiveInt(value, fallback, maxValue) {
|
|
358
|
+
const parsed = intFromValue(value, fallback);
|
|
359
|
+
if (maxValue && parsed > maxValue) return maxValue;
|
|
360
|
+
return parsed;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function ensureObject(value, label) {
|
|
364
|
+
if (value === undefined || value === null) return {};
|
|
365
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
366
|
+
throw new Error(`${label} must be an object`);
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseCommaList(value) {
|
|
372
|
+
if (value === undefined || value === null) return undefined;
|
|
373
|
+
if (Array.isArray(value)) {
|
|
374
|
+
return value
|
|
375
|
+
.map((item) => String(item || '').trim())
|
|
376
|
+
.filter(Boolean);
|
|
377
|
+
}
|
|
378
|
+
return String(value)
|
|
379
|
+
.split(',')
|
|
380
|
+
.map((item) => item.trim())
|
|
381
|
+
.filter(Boolean);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertAllowedKeys(args, allowed, toolName) {
|
|
385
|
+
const allowedSet = new Set(allowed);
|
|
386
|
+
const unknown = Object.keys(args).filter((key) => !allowedSet.has(key));
|
|
387
|
+
if (unknown.length > 0) {
|
|
388
|
+
throw new Error(`${toolName} received unsupported fields: ${unknown.join(', ')}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function classifyToolError(error) {
|
|
393
|
+
const text = (error && error.message ? error.message : '').toLowerCase();
|
|
394
|
+
if (
|
|
395
|
+
text.includes('required') ||
|
|
396
|
+
text.includes('unsupported fields') ||
|
|
397
|
+
text.includes('must be an object') ||
|
|
398
|
+
text.includes('invalid') ||
|
|
399
|
+
text.includes('unknown session_id') ||
|
|
400
|
+
text.includes('unknown transfer_id') ||
|
|
401
|
+
text.includes('ambiguous target') ||
|
|
402
|
+
text.includes('unknown tool') ||
|
|
403
|
+
text.includes('disabled by tool profile')
|
|
404
|
+
) {
|
|
405
|
+
return 'validation_error';
|
|
406
|
+
}
|
|
407
|
+
return 'tool_error';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseCliArgs(argv) {
|
|
411
|
+
const out = {
|
|
412
|
+
showHelp: false,
|
|
413
|
+
maxMessageBytes: undefined,
|
|
414
|
+
toolProfile: undefined,
|
|
415
|
+
defaultJoinTokenSecret: undefined,
|
|
416
|
+
defaultEnforceJoinToken: undefined,
|
|
417
|
+
defaultAllowPeerStreamIDs: undefined,
|
|
418
|
+
defaultRequireSessionMac: undefined
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
422
|
+
const arg = argv[i];
|
|
423
|
+
if (arg === '--help' || arg === '-h') {
|
|
424
|
+
out.showHelp = true;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (arg === '--tool-profile') {
|
|
428
|
+
out.toolProfile = String(argv[++i] || '').trim();
|
|
429
|
+
if (!out.toolProfile) {
|
|
430
|
+
throw new Error('--tool-profile requires a value');
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (arg === '--max-message-bytes') {
|
|
435
|
+
const raw = argv[++i];
|
|
436
|
+
const parsed = intFromValue(raw, NaN);
|
|
437
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
438
|
+
throw new Error('--max-message-bytes must be a positive integer');
|
|
439
|
+
}
|
|
440
|
+
out.maxMessageBytes = parsed;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (arg === '--join-token-secret') {
|
|
444
|
+
const value = argv[++i];
|
|
445
|
+
if (!value || !String(value).trim()) {
|
|
446
|
+
throw new Error('--join-token-secret requires a value');
|
|
447
|
+
}
|
|
448
|
+
out.defaultJoinTokenSecret = String(value).trim();
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (arg === '--enforce-join-token') {
|
|
452
|
+
const value = argv[++i];
|
|
453
|
+
if (value === undefined) {
|
|
454
|
+
throw new Error('--enforce-join-token requires true/false');
|
|
455
|
+
}
|
|
456
|
+
out.defaultEnforceJoinToken = toBool(value, null);
|
|
457
|
+
if (out.defaultEnforceJoinToken === null) {
|
|
458
|
+
throw new Error('--enforce-join-token must be true or false');
|
|
459
|
+
}
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (arg === '--require-session-mac') {
|
|
463
|
+
const value = argv[++i];
|
|
464
|
+
if (value === undefined) {
|
|
465
|
+
throw new Error('--require-session-mac requires true/false');
|
|
466
|
+
}
|
|
467
|
+
out.defaultRequireSessionMac = toBool(value, null);
|
|
468
|
+
if (out.defaultRequireSessionMac === null) {
|
|
469
|
+
throw new Error('--require-session-mac must be true or false');
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (arg === '--allow-peer-stream-ids') {
|
|
474
|
+
const value = argv[++i];
|
|
475
|
+
if (value === undefined) {
|
|
476
|
+
throw new Error('--allow-peer-stream-ids requires comma-separated ids');
|
|
477
|
+
}
|
|
478
|
+
out.defaultAllowPeerStreamIDs = parseCommaList(value);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
throw new Error(`unknown CLI argument: ${arg}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function cliUsage() {
|
|
488
|
+
return `
|
|
489
|
+
Usage:
|
|
490
|
+
vdon-mcp-server [options]
|
|
491
|
+
|
|
492
|
+
Options:
|
|
493
|
+
--tool-profile <core|file|state|full> Default tool profile for this process.
|
|
494
|
+
--max-message-bytes <bytes> Max inbound MCP message size.
|
|
495
|
+
--join-token-secret <secret> Default join token HMAC secret.
|
|
496
|
+
--enforce-join-token <true|false> Default enforce_join_token for vdo_connect.
|
|
497
|
+
--allow-peer-stream-ids <a,b,c> Default stream_id allowlist for peer actions.
|
|
498
|
+
--require-session-mac <true|false> Default require_session_mac for vdo_connect.
|
|
499
|
+
-h, --help Show this help.
|
|
500
|
+
`.trim();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
class VdoMcpServer {
|
|
504
|
+
constructor(options = {}) {
|
|
505
|
+
this.protocolVersion = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
506
|
+
this.sessions = new Map();
|
|
507
|
+
this.sdkFactory = options.sdkFactory || null;
|
|
508
|
+
this.send = options.send || ((message) => process.stdout.write(`${JSON.stringify(message)}\n`));
|
|
509
|
+
this.logger = options.logger || ((message) => process.stderr.write(`${JSON.stringify(message)}\n`));
|
|
510
|
+
this.onExit = options.onExit || (async (code) => process.exit(code));
|
|
511
|
+
|
|
512
|
+
const allTools = buildTools();
|
|
513
|
+
this.allToolNames = new Set(allTools.map((tool) => tool.name));
|
|
514
|
+
const requestedToolProfile = options.toolProfile !== undefined ? options.toolProfile : process.env.VDON_MCP_TOOL_PROFILE;
|
|
515
|
+
const profiledTools = selectToolsForProfile(allTools, requestedToolProfile);
|
|
516
|
+
this.toolProfile = profiledTools.profile;
|
|
517
|
+
this.tools = profiledTools.tools;
|
|
518
|
+
this.allowedToolNames = profiledTools.allowedToolNames;
|
|
519
|
+
|
|
520
|
+
if (this.toolProfile.usedFallback) {
|
|
521
|
+
this.logger({
|
|
522
|
+
type: 'tool_profile_fallback',
|
|
523
|
+
ts: nowISO(),
|
|
524
|
+
requested_profile: this.toolProfile.requested,
|
|
525
|
+
active_profile: this.toolProfile.name,
|
|
526
|
+
default_profile: DEFAULT_TOOL_PROFILE,
|
|
527
|
+
allowed_profiles: PROFILE_NAMES
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
this.defaultJoinTokenSecret = options.defaultJoinTokenSecret || process.env.VDON_MCP_JOIN_TOKEN_SECRET || null;
|
|
532
|
+
this.defaultEnforceJoinToken = toBool(
|
|
533
|
+
options.defaultEnforceJoinToken !== undefined ? options.defaultEnforceJoinToken : process.env.VDON_MCP_ENFORCE_JOIN_TOKEN,
|
|
534
|
+
false
|
|
535
|
+
);
|
|
536
|
+
this.defaultAllowPeerStreamIDs = ensureStringArray(
|
|
537
|
+
options.defaultAllowPeerStreamIDs !== undefined
|
|
538
|
+
? options.defaultAllowPeerStreamIDs
|
|
539
|
+
: process.env.VDON_MCP_ALLOW_PEER_STREAM_IDS
|
|
540
|
+
);
|
|
541
|
+
this.defaultRequireSessionMac = toBool(
|
|
542
|
+
options.defaultRequireSessionMac !== undefined ? options.defaultRequireSessionMac : process.env.VDON_MCP_REQUIRE_SESSION_MAC,
|
|
543
|
+
false
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
sendResult(id, result) {
|
|
548
|
+
this.send({
|
|
549
|
+
jsonrpc: '2.0',
|
|
550
|
+
id,
|
|
551
|
+
result
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
sendError(id, code, message, data) {
|
|
556
|
+
this.send({
|
|
557
|
+
jsonrpc: '2.0',
|
|
558
|
+
id: id === undefined ? null : id,
|
|
559
|
+
error: {
|
|
560
|
+
code,
|
|
561
|
+
message,
|
|
562
|
+
data: data || null
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
parseConnectConfig(rawArgs) {
|
|
568
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
569
|
+
assertAllowedKeys(
|
|
570
|
+
args,
|
|
571
|
+
[
|
|
572
|
+
'room', 'stream_id', 'target_stream_id', 'host', 'password', 'force_turn', 'heartbeat_ms',
|
|
573
|
+
'reconnect_ms', 'max_reconnect_ms', 'label', 'debug',
|
|
574
|
+
'join_token', 'join_token_secret', 'join_token_ttl_ms', 'enforce_join_token', 'allow_peer_stream_ids',
|
|
575
|
+
'file_chunk_bytes', 'file_max_bytes', 'file_ack_timeout_ms', 'file_max_retries', 'require_session_mac',
|
|
576
|
+
'file_spool_dir', 'file_spool_threshold_bytes', 'keep_spool_files',
|
|
577
|
+
'state_max_keys', 'state_max_snapshot_entries'
|
|
578
|
+
],
|
|
579
|
+
'vdo_connect'
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
const room = sanitizeId(args.room, null);
|
|
583
|
+
if (!room) {
|
|
584
|
+
throw new Error('room is required');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (args.host !== undefined && typeof args.host !== 'string') {
|
|
588
|
+
throw new Error('host must be a string');
|
|
589
|
+
}
|
|
590
|
+
if (args.label !== undefined && typeof args.label !== 'string') {
|
|
591
|
+
throw new Error('label must be a string');
|
|
592
|
+
}
|
|
593
|
+
if (args.password !== undefined && typeof args.password !== 'string' && typeof args.password !== 'boolean') {
|
|
594
|
+
throw new Error('password must be a string or boolean');
|
|
595
|
+
}
|
|
596
|
+
if (args.join_token !== undefined && typeof args.join_token !== 'string') {
|
|
597
|
+
throw new Error('join_token must be a string');
|
|
598
|
+
}
|
|
599
|
+
if (args.join_token_secret !== undefined && typeof args.join_token_secret !== 'string') {
|
|
600
|
+
throw new Error('join_token_secret must be a string');
|
|
601
|
+
}
|
|
602
|
+
if (args.file_spool_dir !== undefined && typeof args.file_spool_dir !== 'string') {
|
|
603
|
+
throw new Error('file_spool_dir must be a string');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const streamID = sanitizeId(args.stream_id, generateShortId('mcp_bridge'));
|
|
607
|
+
const targetStreamID = args.target_stream_id ? sanitizeId(args.target_stream_id, null) : null;
|
|
608
|
+
|
|
609
|
+
const joinTokenSecret = typeof args.join_token_secret === 'string' && args.join_token_secret.trim()
|
|
610
|
+
? args.join_token_secret.trim()
|
|
611
|
+
: this.defaultJoinTokenSecret;
|
|
612
|
+
const joinToken = typeof args.join_token === 'string' && args.join_token.trim() ? args.join_token.trim() : null;
|
|
613
|
+
|
|
614
|
+
if (joinToken && joinTokenSecret) {
|
|
615
|
+
const verified = verifyJoinToken(joinToken, joinTokenSecret);
|
|
616
|
+
if (!verified.ok) {
|
|
617
|
+
throw new Error(`invalid join_token: ${verified.error}`);
|
|
618
|
+
}
|
|
619
|
+
const payload = verified.payload || {};
|
|
620
|
+
if (payload.room && sanitizeId(payload.room, payload.room) !== room) {
|
|
621
|
+
throw new Error('join_token room mismatch');
|
|
622
|
+
}
|
|
623
|
+
if (payload.stream_id && sanitizeId(payload.stream_id, payload.stream_id) !== streamID) {
|
|
624
|
+
throw new Error('join_token stream mismatch');
|
|
625
|
+
}
|
|
626
|
+
if (payload.exp && Date.now() > Number(payload.exp)) {
|
|
627
|
+
throw new Error('join_token expired');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const allowPeerStreamIDs = args.allow_peer_stream_ids !== undefined
|
|
632
|
+
? ensureStringArray(args.allow_peer_stream_ids).map((item) => sanitizeId(item, item)).filter(Boolean)
|
|
633
|
+
: this.defaultAllowPeerStreamIDs;
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
host: typeof args.host === 'string' && args.host.trim() ? args.host : 'wss://wss.vdo.ninja',
|
|
637
|
+
room,
|
|
638
|
+
streamID,
|
|
639
|
+
targetStreamID,
|
|
640
|
+
password: parsePassword(args.password),
|
|
641
|
+
forceTURN: toBool(args.force_turn, false),
|
|
642
|
+
debug: toBool(args.debug, false),
|
|
643
|
+
reconnectDelayMs: toPositiveInt(args.reconnect_ms, 3000, 30000),
|
|
644
|
+
maxReconnectDelayMs: toPositiveInt(args.max_reconnect_ms, 30000, 120000),
|
|
645
|
+
heartbeatMs: toPositiveInt(args.heartbeat_ms, 15000, 120000),
|
|
646
|
+
label: typeof args.label === 'string' && args.label.trim() ? args.label : 'mcp_bridge',
|
|
647
|
+
joinToken,
|
|
648
|
+
joinTokenSecret,
|
|
649
|
+
joinTokenTtlMs: toPositiveInt(args.join_token_ttl_ms, 10 * 60 * 1000, 24 * 60 * 60 * 1000),
|
|
650
|
+
enforceJoinToken: toBool(args.enforce_join_token, this.defaultEnforceJoinToken),
|
|
651
|
+
allowPeerStreamIDs,
|
|
652
|
+
fileChunkBytes: toPositiveInt(args.file_chunk_bytes, 64 * 1024, 256 * 1024),
|
|
653
|
+
fileMaxBytes: toPositiveInt(args.file_max_bytes, 64 * 1024 * 1024, 256 * 1024 * 1024),
|
|
654
|
+
fileAckTimeoutMs: toPositiveInt(args.file_ack_timeout_ms, 1200, 30000),
|
|
655
|
+
fileMaxRetries: nonNegativeIntFromValue(args.file_max_retries, 6),
|
|
656
|
+
requireSessionMac: toBool(args.require_session_mac, this.defaultRequireSessionMac),
|
|
657
|
+
fileSpoolDir: typeof args.file_spool_dir === 'string' && args.file_spool_dir.trim() ? args.file_spool_dir.trim() : undefined,
|
|
658
|
+
fileSpoolThresholdBytes: toPositiveInt(args.file_spool_threshold_bytes, 4 * 1024 * 1024, 512 * 1024 * 1024),
|
|
659
|
+
keepSpoolFiles: toBool(args.keep_spool_files, false),
|
|
660
|
+
stateMaxKeys: toPositiveInt(args.state_max_keys, 2048, 50000),
|
|
661
|
+
stateMaxSnapshotEntries: toPositiveInt(args.state_max_snapshot_entries, 4096, 100000)
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
getSessionOrThrow(sessionId) {
|
|
666
|
+
const session = this.sessions.get(sessionId);
|
|
667
|
+
if (!session) {
|
|
668
|
+
throw new Error(`unknown session_id: ${sessionId}`);
|
|
669
|
+
}
|
|
670
|
+
return session;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
assertToolAvailable(name) {
|
|
674
|
+
if (this.allowedToolNames.has(name)) return;
|
|
675
|
+
if (this.allToolNames.has(name)) {
|
|
676
|
+
throw new Error(`tool '${name}' is disabled by tool profile '${this.toolProfile.name}'`);
|
|
677
|
+
}
|
|
678
|
+
throw new Error(`unknown tool: ${name}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async callTool(name, rawArgs) {
|
|
682
|
+
this.assertToolAvailable(name);
|
|
683
|
+
switch (name) {
|
|
684
|
+
case 'vdo_connect':
|
|
685
|
+
return this.toolConnect(rawArgs);
|
|
686
|
+
case 'vdo_send':
|
|
687
|
+
return this.toolSend(rawArgs);
|
|
688
|
+
case 'vdo_receive':
|
|
689
|
+
return this.toolReceive(rawArgs);
|
|
690
|
+
case 'vdo_status':
|
|
691
|
+
return this.toolStatus(rawArgs);
|
|
692
|
+
case 'vdo_disconnect':
|
|
693
|
+
return this.toolDisconnect(rawArgs);
|
|
694
|
+
case 'vdo_list_sessions':
|
|
695
|
+
return this.toolListSessions(rawArgs);
|
|
696
|
+
case 'vdo_capabilities':
|
|
697
|
+
return this.toolCapabilities(rawArgs);
|
|
698
|
+
case 'vdo_sync_peers':
|
|
699
|
+
return this.toolSyncPeers(rawArgs);
|
|
700
|
+
case 'vdo_sync_announce':
|
|
701
|
+
return this.toolSyncAnnounce(rawArgs);
|
|
702
|
+
case 'vdo_file_send':
|
|
703
|
+
return this.toolFileSend(rawArgs);
|
|
704
|
+
case 'vdo_file_send_path':
|
|
705
|
+
return this.toolFileSendPath(rawArgs);
|
|
706
|
+
case 'vdo_file_resume':
|
|
707
|
+
return this.toolFileResume(rawArgs);
|
|
708
|
+
case 'vdo_file_transfers':
|
|
709
|
+
return this.toolFileTransfers(rawArgs);
|
|
710
|
+
case 'vdo_file_receive':
|
|
711
|
+
return this.toolFileReceive(rawArgs);
|
|
712
|
+
case 'vdo_file_save':
|
|
713
|
+
return this.toolFileSave(rawArgs);
|
|
714
|
+
case 'vdo_state_set':
|
|
715
|
+
return this.toolStateSet(rawArgs);
|
|
716
|
+
case 'vdo_state_get':
|
|
717
|
+
return this.toolStateGet(rawArgs);
|
|
718
|
+
case 'vdo_state_sync':
|
|
719
|
+
return this.toolStateSync(rawArgs);
|
|
720
|
+
default:
|
|
721
|
+
throw new Error(`unknown tool: ${name}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async toolConnect(rawArgs) {
|
|
726
|
+
const config = this.parseConnectConfig(rawArgs);
|
|
727
|
+
const sessionId = `vdo_${randomUUID()}`;
|
|
728
|
+
|
|
729
|
+
const session = new BridgeSession(config, {
|
|
730
|
+
id: sessionId,
|
|
731
|
+
sdkFactory: this.sdkFactory || undefined
|
|
732
|
+
});
|
|
733
|
+
this.sessions.set(sessionId, session);
|
|
734
|
+
|
|
735
|
+
session.on('event', (event) => {
|
|
736
|
+
if (['connect_error', 'sdk_error', 'connection_failed', 'disconnected', 'send_error', 'protocol_auth_failed'].includes(event.type)) {
|
|
737
|
+
this.logger({
|
|
738
|
+
type: 'session_event',
|
|
739
|
+
ts: nowISO(),
|
|
740
|
+
session_id: sessionId,
|
|
741
|
+
event
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await session.start();
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
session_id: sessionId,
|
|
750
|
+
...session.getStatus(),
|
|
751
|
+
effective_config: {
|
|
752
|
+
host: config.host,
|
|
753
|
+
room: config.room,
|
|
754
|
+
stream_id: config.streamID,
|
|
755
|
+
target_stream_id: config.targetStreamID,
|
|
756
|
+
force_turn: config.forceTURN,
|
|
757
|
+
heartbeat_ms: config.heartbeatMs,
|
|
758
|
+
reconnect_ms: config.reconnectDelayMs,
|
|
759
|
+
max_reconnect_ms: config.maxReconnectDelayMs,
|
|
760
|
+
enforce_join_token: config.enforceJoinToken,
|
|
761
|
+
allow_peer_stream_ids: config.allowPeerStreamIDs,
|
|
762
|
+
file_chunk_bytes: config.fileChunkBytes,
|
|
763
|
+
file_max_bytes: config.fileMaxBytes,
|
|
764
|
+
file_ack_timeout_ms: config.fileAckTimeoutMs,
|
|
765
|
+
file_max_retries: config.fileMaxRetries,
|
|
766
|
+
require_session_mac: config.requireSessionMac,
|
|
767
|
+
file_spool_dir: config.fileSpoolDir,
|
|
768
|
+
file_spool_threshold_bytes: config.fileSpoolThresholdBytes,
|
|
769
|
+
keep_spool_files: config.keepSpoolFiles,
|
|
770
|
+
state_max_keys: config.stateMaxKeys,
|
|
771
|
+
state_max_snapshot_entries: config.stateMaxSnapshotEntries
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async toolSend(rawArgs) {
|
|
777
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
778
|
+
assertAllowedKeys(args, ['session_id', 'data', 'target', 'uuid'], 'vdo_send');
|
|
779
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
780
|
+
if (!Object.prototype.hasOwnProperty.call(args, 'data')) throw new Error('data is required');
|
|
781
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
782
|
+
throw new Error('provide target or uuid, not both');
|
|
783
|
+
}
|
|
784
|
+
if (args.target !== undefined && typeof args.target !== 'string' && typeof args.target !== 'object') {
|
|
785
|
+
throw new Error('target must be a string or object');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
789
|
+
const target = args.target || args.uuid || null;
|
|
790
|
+
const ok = session.send(args.data, target);
|
|
791
|
+
return {
|
|
792
|
+
ok,
|
|
793
|
+
session_id: args.session_id,
|
|
794
|
+
used_target: target || null
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async toolReceive(rawArgs) {
|
|
799
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
800
|
+
assertAllowedKeys(args, ['session_id', 'max_events', 'wait_ms'], 'vdo_receive');
|
|
801
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
802
|
+
|
|
803
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
804
|
+
const maxEvents = toPositiveInt(args.max_events, 50, 500);
|
|
805
|
+
const waitMs = args.wait_ms === 0 ? 0 : toPositiveInt(args.wait_ms, 0, 30000);
|
|
806
|
+
const events = await session.pollEvents(maxEvents, waitMs);
|
|
807
|
+
return {
|
|
808
|
+
ok: true,
|
|
809
|
+
session_id: args.session_id,
|
|
810
|
+
event_count: events.length,
|
|
811
|
+
events
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async toolStatus(rawArgs) {
|
|
816
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
817
|
+
assertAllowedKeys(args, ['session_id'], 'vdo_status');
|
|
818
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
819
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
820
|
+
return {
|
|
821
|
+
ok: true,
|
|
822
|
+
...session.getStatus(),
|
|
823
|
+
sync_peers: session.listSyncPeers()
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async toolDisconnect(rawArgs) {
|
|
828
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
829
|
+
assertAllowedKeys(args, ['session_id'], 'vdo_disconnect');
|
|
830
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
831
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
832
|
+
await session.stop();
|
|
833
|
+
this.sessions.delete(args.session_id);
|
|
834
|
+
return {
|
|
835
|
+
ok: true,
|
|
836
|
+
session_id: args.session_id,
|
|
837
|
+
closed_at: nowISO()
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async toolListSessions(rawArgs) {
|
|
842
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
843
|
+
assertAllowedKeys(args, [], 'vdo_list_sessions');
|
|
844
|
+
return {
|
|
845
|
+
ok: true,
|
|
846
|
+
sessions: Array.from(this.sessions.values()).map((session) => session.getStatus())
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async toolCapabilities(rawArgs) {
|
|
851
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
852
|
+
assertAllowedKeys(args, [], 'vdo_capabilities');
|
|
853
|
+
const toolNames = this.tools.map((tool) => tool.name);
|
|
854
|
+
const hasFileTools = toolNames.some((name) => name.startsWith('vdo_file_'));
|
|
855
|
+
const hasStateTools = toolNames.some((name) => name.startsWith('vdo_state_'));
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
server: {
|
|
859
|
+
...SERVER_INFO,
|
|
860
|
+
protocol_version: this.protocolVersion,
|
|
861
|
+
supported_protocol_versions: SUPPORTED_PROTOCOL_VERSIONS
|
|
862
|
+
},
|
|
863
|
+
tool_profile: {
|
|
864
|
+
name: this.toolProfile.name,
|
|
865
|
+
requested: this.toolProfile.requested,
|
|
866
|
+
allowed_profiles: PROFILE_NAMES,
|
|
867
|
+
tools: toolNames
|
|
868
|
+
},
|
|
869
|
+
features: {
|
|
870
|
+
session_messaging: true,
|
|
871
|
+
file_transfer: hasFileTools,
|
|
872
|
+
shared_state_sync: hasStateTools
|
|
873
|
+
},
|
|
874
|
+
transport_truth: {
|
|
875
|
+
type: 'webrtc_data_channel',
|
|
876
|
+
generic_tcp_tunnel: false,
|
|
877
|
+
turn_relays_help_reachability: true,
|
|
878
|
+
guaranteed_firewall_bypass: false,
|
|
879
|
+
reliability: 'SCTP data channels + MCP file ACK/retry/resume'
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async toolSyncPeers(rawArgs) {
|
|
885
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
886
|
+
assertAllowedKeys(args, ['session_id'], 'vdo_sync_peers');
|
|
887
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
888
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
ok: true,
|
|
892
|
+
session_id: args.session_id,
|
|
893
|
+
peer_count: session.listSyncPeers().length,
|
|
894
|
+
peers: session.listSyncPeers()
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async toolSyncAnnounce(rawArgs) {
|
|
899
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
900
|
+
assertAllowedKeys(args, ['session_id', 'target', 'uuid'], 'vdo_sync_announce');
|
|
901
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
902
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
903
|
+
throw new Error('provide target or uuid, not both');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
907
|
+
const target = args.target || args.uuid || null;
|
|
908
|
+
const ok = session.sendSyncHello(target);
|
|
909
|
+
return {
|
|
910
|
+
ok,
|
|
911
|
+
session_id: args.session_id,
|
|
912
|
+
used_target: target || null
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async toolFileSend(rawArgs) {
|
|
917
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
918
|
+
assertAllowedKeys(
|
|
919
|
+
args,
|
|
920
|
+
['session_id', 'data_base64', 'name', 'mime', 'target', 'uuid', 'transfer_id', 'chunk_bytes', 'timeout_ms', 'ack_timeout_ms', 'max_retries'],
|
|
921
|
+
'vdo_file_send'
|
|
922
|
+
);
|
|
923
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
924
|
+
if (!args.data_base64 || typeof args.data_base64 !== 'string') {
|
|
925
|
+
throw new Error('data_base64 is required');
|
|
926
|
+
}
|
|
927
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
928
|
+
throw new Error('provide target or uuid, not both');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
932
|
+
const dataBuffer = Buffer.from(args.data_base64, 'base64');
|
|
933
|
+
if (!dataBuffer.length) {
|
|
934
|
+
throw new Error('data_base64 decoded to empty payload');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const transfer = await session.sendFile({
|
|
938
|
+
dataBuffer,
|
|
939
|
+
target: args.target || args.uuid || null,
|
|
940
|
+
transferId: args.transfer_id,
|
|
941
|
+
name: args.name,
|
|
942
|
+
mime: args.mime,
|
|
943
|
+
chunkBytes: args.chunk_bytes,
|
|
944
|
+
timeoutMs: args.timeout_ms,
|
|
945
|
+
ackTimeoutMs: args.ack_timeout_ms,
|
|
946
|
+
maxRetries: args.max_retries
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
ok: true,
|
|
951
|
+
session_id: args.session_id,
|
|
952
|
+
transfer
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async toolFileSendPath(rawArgs) {
|
|
957
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
958
|
+
assertAllowedKeys(
|
|
959
|
+
args,
|
|
960
|
+
['session_id', 'file_path', 'name', 'mime', 'target', 'uuid', 'transfer_id', 'chunk_bytes', 'timeout_ms', 'ack_timeout_ms', 'max_retries'],
|
|
961
|
+
'vdo_file_send_path'
|
|
962
|
+
);
|
|
963
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
964
|
+
if (!args.file_path || typeof args.file_path !== 'string') {
|
|
965
|
+
throw new Error('file_path is required');
|
|
966
|
+
}
|
|
967
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
968
|
+
throw new Error('provide target or uuid, not both');
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
972
|
+
const transfer = await session.sendFileFromPath({
|
|
973
|
+
filePath: args.file_path,
|
|
974
|
+
target: args.target || args.uuid || null,
|
|
975
|
+
transferId: args.transfer_id,
|
|
976
|
+
name: args.name,
|
|
977
|
+
mime: args.mime,
|
|
978
|
+
chunkBytes: args.chunk_bytes,
|
|
979
|
+
timeoutMs: args.timeout_ms,
|
|
980
|
+
ackTimeoutMs: args.ack_timeout_ms,
|
|
981
|
+
maxRetries: args.max_retries
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
return {
|
|
985
|
+
ok: true,
|
|
986
|
+
session_id: args.session_id,
|
|
987
|
+
transfer
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async toolFileResume(rawArgs) {
|
|
992
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
993
|
+
assertAllowedKeys(args, ['session_id', 'transfer_id', 'target', 'start_seq', 'ack_timeout_ms', 'max_retries'], 'vdo_file_resume');
|
|
994
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
995
|
+
if (!args.transfer_id) throw new Error('transfer_id is required');
|
|
996
|
+
|
|
997
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
998
|
+
const transfer = await session.resumeOutgoingTransfer(args.transfer_id, {
|
|
999
|
+
target: args.target,
|
|
1000
|
+
startSeq: args.start_seq,
|
|
1001
|
+
ackTimeoutMs: args.ack_timeout_ms,
|
|
1002
|
+
maxRetries: args.max_retries
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
session_id: args.session_id,
|
|
1008
|
+
transfer
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async toolFileTransfers(rawArgs) {
|
|
1013
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1014
|
+
assertAllowedKeys(args, ['session_id', 'direction'], 'vdo_file_transfers');
|
|
1015
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1016
|
+
if (args.direction !== undefined && !['incoming', 'outgoing', 'all'].includes(String(args.direction))) {
|
|
1017
|
+
throw new Error('direction must be incoming, outgoing, or all');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1021
|
+
const direction = args.direction ? String(args.direction) : 'all';
|
|
1022
|
+
const transfers = session.listTransfers(direction);
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
ok: true,
|
|
1026
|
+
session_id: args.session_id,
|
|
1027
|
+
direction,
|
|
1028
|
+
incoming_count: transfers.incoming.length,
|
|
1029
|
+
outgoing_count: transfers.outgoing.length,
|
|
1030
|
+
...transfers
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async toolFileReceive(rawArgs) {
|
|
1035
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1036
|
+
assertAllowedKeys(args, ['session_id', 'transfer_id', 'encoding'], 'vdo_file_receive');
|
|
1037
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1038
|
+
if (!args.transfer_id) throw new Error('transfer_id is required');
|
|
1039
|
+
|
|
1040
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1041
|
+
const result = session.readIncomingTransfer(args.transfer_id, args.encoding || 'base64');
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
ok: true,
|
|
1045
|
+
session_id: args.session_id,
|
|
1046
|
+
transfer_id: args.transfer_id,
|
|
1047
|
+
...result
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async toolFileSave(rawArgs) {
|
|
1052
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1053
|
+
assertAllowedKeys(args, ['session_id', 'transfer_id', 'output_path', 'overwrite'], 'vdo_file_save');
|
|
1054
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1055
|
+
if (!args.transfer_id) throw new Error('transfer_id is required');
|
|
1056
|
+
if (!args.output_path || typeof args.output_path !== 'string') {
|
|
1057
|
+
throw new Error('output_path is required');
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1061
|
+
const result = session.saveIncomingTransferToPath(args.transfer_id, args.output_path, toBool(args.overwrite, false));
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
session_id: args.session_id,
|
|
1065
|
+
transfer_id: args.transfer_id,
|
|
1066
|
+
...result
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async toolStateSet(rawArgs) {
|
|
1071
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1072
|
+
assertAllowedKeys(args, ['session_id', 'key', 'value', 'broadcast', 'target', 'uuid'], 'vdo_state_set');
|
|
1073
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1074
|
+
if (!args.key || typeof args.key !== 'string') throw new Error('key is required');
|
|
1075
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
1076
|
+
throw new Error('provide target or uuid, not both');
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1080
|
+
const target = args.target || args.uuid || null;
|
|
1081
|
+
const entry = session.setLocalState(args.key, args.value, {
|
|
1082
|
+
broadcast: toBool(args.broadcast, true),
|
|
1083
|
+
target
|
|
1084
|
+
});
|
|
1085
|
+
return {
|
|
1086
|
+
ok: true,
|
|
1087
|
+
session_id: args.session_id,
|
|
1088
|
+
entry
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async toolStateGet(rawArgs) {
|
|
1093
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1094
|
+
assertAllowedKeys(args, ['session_id', 'key', 'include_meta'], 'vdo_state_get');
|
|
1095
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1096
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1097
|
+
const includeMeta = toBool(args.include_meta, false);
|
|
1098
|
+
const value = session.getStateValue(args.key, includeMeta);
|
|
1099
|
+
return {
|
|
1100
|
+
ok: true,
|
|
1101
|
+
session_id: args.session_id,
|
|
1102
|
+
key: args.key || null,
|
|
1103
|
+
include_meta: includeMeta,
|
|
1104
|
+
value
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async toolStateSync(rawArgs) {
|
|
1109
|
+
const args = ensureObject(rawArgs, 'arguments');
|
|
1110
|
+
assertAllowedKeys(args, ['session_id', 'mode', 'target', 'uuid'], 'vdo_state_sync');
|
|
1111
|
+
if (!args.session_id) throw new Error('session_id is required');
|
|
1112
|
+
if (args.target !== undefined && args.uuid !== undefined) {
|
|
1113
|
+
throw new Error('provide target or uuid, not both');
|
|
1114
|
+
}
|
|
1115
|
+
const mode = args.mode ? String(args.mode) : 'request';
|
|
1116
|
+
if (!['request', 'send'].includes(mode)) {
|
|
1117
|
+
throw new Error('mode must be request or send');
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const session = this.getSessionOrThrow(args.session_id);
|
|
1121
|
+
const target = args.target || args.uuid || null;
|
|
1122
|
+
const ok = mode === 'send'
|
|
1123
|
+
? session.sendStateSnapshot(target)
|
|
1124
|
+
: session.requestStateSnapshot(target);
|
|
1125
|
+
return {
|
|
1126
|
+
ok,
|
|
1127
|
+
session_id: args.session_id,
|
|
1128
|
+
mode,
|
|
1129
|
+
used_target: target || null
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async shutdownAll() {
|
|
1134
|
+
const all = Array.from(this.sessions.values());
|
|
1135
|
+
this.sessions.clear();
|
|
1136
|
+
for (const session of all) {
|
|
1137
|
+
try {
|
|
1138
|
+
await session.stop();
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
// Ignore per-session shutdown errors.
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async handleRequest(message) {
|
|
1146
|
+
const { id, method } = message;
|
|
1147
|
+
|
|
1148
|
+
if (method === 'initialize') {
|
|
1149
|
+
const requestedVersion = message.params && message.params.protocolVersion;
|
|
1150
|
+
if (requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)) {
|
|
1151
|
+
this.protocolVersion = requestedVersion;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
this.sendResult(id, {
|
|
1155
|
+
protocolVersion: this.protocolVersion,
|
|
1156
|
+
capabilities: {
|
|
1157
|
+
tools: {
|
|
1158
|
+
listChanged: false
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
serverInfo: SERVER_INFO
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (method === 'ping') {
|
|
1167
|
+
this.sendResult(id, {});
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (method === 'tools/list') {
|
|
1172
|
+
this.sendResult(id, {
|
|
1173
|
+
tools: this.tools,
|
|
1174
|
+
_meta: {
|
|
1175
|
+
tool_profile: this.toolProfile.name,
|
|
1176
|
+
requested_tool_profile: this.toolProfile.requested,
|
|
1177
|
+
allowed_tool_profiles: PROFILE_NAMES
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (method === 'resources/list') {
|
|
1184
|
+
this.sendResult(id, { resources: [] });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (method === 'prompts/list') {
|
|
1189
|
+
this.sendResult(id, { prompts: [] });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (method === 'shutdown') {
|
|
1194
|
+
await this.shutdownAll();
|
|
1195
|
+
this.sendResult(id, {});
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (method === 'tools/call') {
|
|
1200
|
+
const params = ensureObject(message.params, 'tools/call params');
|
|
1201
|
+
const toolName = params.name;
|
|
1202
|
+
const args = params.arguments || {};
|
|
1203
|
+
if (!toolName) {
|
|
1204
|
+
this.sendResult(
|
|
1205
|
+
id,
|
|
1206
|
+
makeToolResult(
|
|
1207
|
+
{
|
|
1208
|
+
ok: false,
|
|
1209
|
+
error: {
|
|
1210
|
+
type: 'validation_error',
|
|
1211
|
+
message: 'tools/call requires params.name'
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
true
|
|
1215
|
+
)
|
|
1216
|
+
);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
try {
|
|
1221
|
+
const payload = await this.callTool(toolName, args);
|
|
1222
|
+
this.sendResult(id, makeToolResult(payload, false));
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
this.sendResult(
|
|
1225
|
+
id,
|
|
1226
|
+
makeToolResult(
|
|
1227
|
+
{
|
|
1228
|
+
ok: false,
|
|
1229
|
+
error: {
|
|
1230
|
+
type: classifyToolError(error),
|
|
1231
|
+
message: error.message,
|
|
1232
|
+
tool: toolName
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
true
|
|
1236
|
+
)
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
this.sendError(id, -32601, `Method not found: ${method}`);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async handleNotification(message) {
|
|
1246
|
+
const { method } = message;
|
|
1247
|
+
if (method === 'notifications/initialized') {
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (method === 'notifications/cancelled') {
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (method === 'exit') {
|
|
1254
|
+
await this.shutdownAll();
|
|
1255
|
+
await this.onExit(0);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async dispatchSingleMessage(message, fromBatch) {
|
|
1260
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
1261
|
+
this.sendError(null, -32600, 'Invalid Request');
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const hasId = Object.prototype.hasOwnProperty.call(message, 'id');
|
|
1266
|
+
if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
1267
|
+
if (!fromBatch || hasId) {
|
|
1268
|
+
this.sendError(hasId ? message.id : null, -32600, 'Invalid Request');
|
|
1269
|
+
}
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
try {
|
|
1274
|
+
if (hasId) {
|
|
1275
|
+
await this.handleRequest(message);
|
|
1276
|
+
} else {
|
|
1277
|
+
await this.handleNotification(message);
|
|
1278
|
+
}
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (hasId) {
|
|
1281
|
+
this.sendError(message.id, -32000, error.message);
|
|
1282
|
+
} else {
|
|
1283
|
+
this.logger({
|
|
1284
|
+
type: 'notification_error',
|
|
1285
|
+
ts: nowISO(),
|
|
1286
|
+
method: message.method,
|
|
1287
|
+
message: error.message
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async dispatchMessage(payload) {
|
|
1294
|
+
if (Array.isArray(payload)) {
|
|
1295
|
+
if (payload.length === 0) {
|
|
1296
|
+
this.sendError(null, -32600, 'Invalid Request');
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
for (const message of payload) {
|
|
1300
|
+
await this.dispatchSingleMessage(message, true);
|
|
1301
|
+
}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
await this.dispatchSingleMessage(payload, false);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function runMcpStdioLoop(options = {}) {
|
|
1309
|
+
let outputMode = 'line';
|
|
1310
|
+
const input = options.input || process.stdin;
|
|
1311
|
+
const output = options.output || process.stdout;
|
|
1312
|
+
const errorOutput = options.errorOutput || process.stderr;
|
|
1313
|
+
const maxMessageBytes = intFromValue(options.maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES);
|
|
1314
|
+
|
|
1315
|
+
const send = options.send || ((message) => writeMcpMessage(output, message, outputMode));
|
|
1316
|
+
const logger = options.logger || ((message) => errorOutput.write(`${JSON.stringify(message)}\n`));
|
|
1317
|
+
const onExit = options.onExit || (async (code) => process.exit(code));
|
|
1318
|
+
|
|
1319
|
+
const server = options.server || new VdoMcpServer({
|
|
1320
|
+
sdkFactory: options.sdkFactory || null,
|
|
1321
|
+
toolProfile: options.toolProfile,
|
|
1322
|
+
defaultJoinTokenSecret: options.defaultJoinTokenSecret,
|
|
1323
|
+
defaultEnforceJoinToken: options.defaultEnforceJoinToken,
|
|
1324
|
+
defaultAllowPeerStreamIDs: options.defaultAllowPeerStreamIDs,
|
|
1325
|
+
defaultRequireSessionMac: options.defaultRequireSessionMac,
|
|
1326
|
+
send,
|
|
1327
|
+
logger,
|
|
1328
|
+
onExit
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
const parser = new McpStdioParser({ maxMessageBytes });
|
|
1332
|
+
let chain = Promise.resolve();
|
|
1333
|
+
let closed = false;
|
|
1334
|
+
|
|
1335
|
+
parser.on('message', ({ message, mode }) => {
|
|
1336
|
+
if (mode === 'framed') {
|
|
1337
|
+
outputMode = 'framed';
|
|
1338
|
+
}
|
|
1339
|
+
chain = chain
|
|
1340
|
+
.then(() => server.dispatchMessage(message))
|
|
1341
|
+
.catch((error) => {
|
|
1342
|
+
server.sendError(null, -32000, error.message);
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
parser.on('error', (error) => {
|
|
1347
|
+
if (error.code === 'parse_error') {
|
|
1348
|
+
server.sendError(null, -32700, 'Parse error', { message: error.message });
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
server.sendError(null, -32600, 'Invalid Request', { code: error.code, message: error.message });
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
input.on('data', (chunk) => {
|
|
1355
|
+
parser.push(chunk);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
const closeHandler = async () => {
|
|
1359
|
+
if (closed) return;
|
|
1360
|
+
closed = true;
|
|
1361
|
+
try {
|
|
1362
|
+
await chain;
|
|
1363
|
+
await server.shutdownAll();
|
|
1364
|
+
if (options.exitOnClose !== false) {
|
|
1365
|
+
await onExit(0);
|
|
1366
|
+
}
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
logger({
|
|
1369
|
+
type: 'loop_close_error',
|
|
1370
|
+
ts: nowISO(),
|
|
1371
|
+
message: error.message
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
input.on('end', closeHandler);
|
|
1377
|
+
input.on('close', closeHandler);
|
|
1378
|
+
return server;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function main() {
|
|
1382
|
+
const cli = parseCliArgs(process.argv.slice(2));
|
|
1383
|
+
if (cli.showHelp) {
|
|
1384
|
+
process.stdout.write(`${cliUsage()}\n`);
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
let sdkFactory;
|
|
1389
|
+
if (process.env.VDON_MCP_FAKE === '1') {
|
|
1390
|
+
const { createFakeSDKFactory } = require('./lib/fake-network-sdk');
|
|
1391
|
+
sdkFactory = createFakeSDKFactory();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
await runMcpStdioLoop({
|
|
1395
|
+
sdkFactory,
|
|
1396
|
+
toolProfile: cli.toolProfile,
|
|
1397
|
+
defaultJoinTokenSecret: cli.defaultJoinTokenSecret,
|
|
1398
|
+
defaultEnforceJoinToken: cli.defaultEnforceJoinToken,
|
|
1399
|
+
defaultAllowPeerStreamIDs: cli.defaultAllowPeerStreamIDs,
|
|
1400
|
+
defaultRequireSessionMac: cli.defaultRequireSessionMac,
|
|
1401
|
+
maxMessageBytes: cli.maxMessageBytes !== undefined
|
|
1402
|
+
? cli.maxMessageBytes
|
|
1403
|
+
: intFromEnv(process.env.VDON_MCP_MAX_MESSAGE_BYTES, DEFAULT_MAX_MESSAGE_BYTES)
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (require.main === module) {
|
|
1408
|
+
main().catch((error) => {
|
|
1409
|
+
process.stderr.write(`${JSON.stringify({
|
|
1410
|
+
type: 'fatal',
|
|
1411
|
+
ts: nowISO(),
|
|
1412
|
+
message: error.message
|
|
1413
|
+
})}\n`);
|
|
1414
|
+
process.exit(1);
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
module.exports = {
|
|
1419
|
+
VdoMcpServer,
|
|
1420
|
+
runMcpStdioLoop,
|
|
1421
|
+
buildTools,
|
|
1422
|
+
makeToolResult,
|
|
1423
|
+
classifyToolError,
|
|
1424
|
+
parseCliArgs
|
|
1425
|
+
};
|