@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,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
+ };