@vdoninja/mcp 0.4.0 → 0.4.2

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/SKILL.md +8 -6
  4. package/assets/browser-agent/audio-worklet.js +17 -0
  5. package/assets/browser-agent/client.js +124 -0
  6. package/assets/browser-agent/index.html +50 -0
  7. package/assets/cloudflare/worker-proxy.js +3 -0
  8. package/docs/.nojekyll +0 -0
  9. package/docs/404.html +28 -0
  10. package/docs/advanced-diagnostics.html +101 -0
  11. package/docs/assets/apple-touch-icon.png +0 -0
  12. package/docs/assets/favicon.svg +7 -0
  13. package/docs/assets/how-it-works.svg +62 -0
  14. package/docs/assets/icon-192.png +0 -0
  15. package/docs/assets/icon-512.png +0 -0
  16. package/docs/assets/icon-maskable-512.png +0 -0
  17. package/docs/assets/icons.svg +42 -0
  18. package/docs/assets/logo.svg +7 -0
  19. package/docs/assets/og-image.png +0 -0
  20. package/docs/assets/site.css +735 -0
  21. package/docs/assets/site.js +289 -0
  22. package/docs/browser-agent.html +122 -0
  23. package/docs/browser-worker.html +165 -0
  24. package/docs/client-config-examples.html +254 -0
  25. package/docs/direction.html +175 -0
  26. package/docs/favicon.ico +0 -0
  27. package/docs/guide.html +872 -0
  28. package/docs/http-relay.html +154 -0
  29. package/docs/index.html +834 -0
  30. package/docs/mcp-tool-contract.html +226 -0
  31. package/docs/quickstart-and-compat.html +222 -0
  32. package/docs/site.webmanifest +16 -0
  33. package/docs/sitemap.xml +63 -0
  34. package/docs/ssapp-integration.html +594 -0
  35. package/docs/vdo-ninja-p2p-data-notes.html +225 -0
  36. package/package.json +67 -11
  37. package/references/advanced-diagnostics.md +6 -4
  38. package/references/browser-agent.md +53 -0
  39. package/references/browser-worker.md +142 -0
  40. package/references/client-config-examples.md +253 -0
  41. package/references/direction.md +88 -0
  42. package/references/http-relay.md +107 -0
  43. package/references/mcp-tool-contract.md +16 -6
  44. package/references/quickstart-and-compat.md +18 -6
  45. package/references/ssapp-integration.md +371 -0
  46. package/references/vdo-ninja-p2p-data-notes.md +3 -1
  47. package/scripts/demo-file-workflow.js +25 -6
  48. package/scripts/demo-message-workflow.js +25 -6
  49. package/scripts/install-mcp.js +55 -4
  50. package/scripts/lib/bridge-session.js +143 -26
  51. package/scripts/lib/bridge-utils.js +3 -0
  52. package/scripts/lib/browser-agent.js +255 -0
  53. package/scripts/lib/browser-media.js +79 -0
  54. package/scripts/lib/browser-routes.js +119 -0
  55. package/scripts/lib/browser-rpc.js +214 -0
  56. package/scripts/lib/browser-setup.js +133 -0
  57. package/scripts/lib/browser-worker.js +216 -0
  58. package/scripts/lib/fake-network-sdk.js +3 -0
  59. package/scripts/lib/http-relay-transport.js +294 -0
  60. package/scripts/lib/load-vdo-sdk.js +3 -0
  61. package/scripts/lib/mcp-stdio-parser.js +3 -0
  62. package/scripts/lib/ssapp-worker.js +198 -0
  63. package/scripts/lib/tool-profiles.js +3 -0
  64. package/scripts/vdo-data-bridge.js +3 -0
  65. package/scripts/vdo-mcp-browser-agent.js +132 -0
  66. package/scripts/vdo-mcp-browser.js +218 -0
  67. package/scripts/vdo-mcp-relay.js +207 -0
  68. package/scripts/vdo-mcp-server.js +87 -8
  69. package/scripts/vdo-mcp-ssapp.js +208 -0
  70. package/scripts/vdo-mcp-streamable-http.js +23 -4
  71. package/server.json +26 -10
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Steve Seguin
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ 'use strict';
6
+
7
+ const http = require('node:http');
8
+ const crypto = require('node:crypto');
9
+
10
+ const MAX_BODY_BYTES = 768 * 1024;
11
+ const RETENTION_MS = 5 * 60 * 1000;
12
+
13
+ function json(res, status, body) {
14
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
15
+ res.end(JSON.stringify(body));
16
+ }
17
+
18
+ // One relay instance serves a trusted group. The bearer token grants relay access;
19
+ // room encryption keys stay on the clients and are never sent to this server.
20
+ async function startRelayServer(options = {}) {
21
+ const token = options.token || process.env.VDON_RELAY_TOKEN;
22
+ if (typeof token !== 'string' || token.length < 32 || /[^\x21-\x7e]/.test(token)) {
23
+ throw new Error('VDON_RELAY_TOKEN requires at least 32 printable non-space characters');
24
+ }
25
+ const expectedAuth = crypto.createHash('sha256').update(`Bearer ${token}`).digest();
26
+ const rooms = new Map();
27
+ const responses = new Set();
28
+ const sockets = new Set();
29
+ let closing = false;
30
+ let closePromise;
31
+
32
+ function prune(room) {
33
+ const cutoff = Date.now() - RETENTION_MS;
34
+ while (room.events.length && (room.events.length > 512 || room.bytes > 8 * 1024 * 1024 || room.events[0].ts < cutoff)) {
35
+ const removed = room.events.shift();
36
+ room.bytes -= removed.bytes;
37
+ room.ids.delete(removed.id);
38
+ }
39
+ }
40
+
41
+ function readEvents(room, cursor) {
42
+ prune(room);
43
+ const latest = `${room.epoch}:${room.seq}`;
44
+ if (cursor === null) return { cursor: latest, events: [] };
45
+ const match = /^([a-f0-9-]{36}):(\d+)$/.exec(cursor);
46
+ const seq = match ? Number(match[2]) : NaN;
47
+ const oldest = room.events.length ? room.events[0].seq : room.seq + 1;
48
+ if (!match || match[1] !== room.epoch || !Number.isSafeInteger(seq) || seq < oldest - 1 || seq > room.seq) {
49
+ return { error: 'relay_history_lost', cursor: latest, events: [] };
50
+ }
51
+ const events = [];
52
+ let bytes = 0;
53
+ for (const event of room.events) {
54
+ if (event.seq <= seq) continue;
55
+ if (events.length && bytes + event.bytes > 1024 * 1024) break;
56
+ bytes += event.bytes;
57
+ events.push({ id: event.id, box: event.box });
58
+ }
59
+ return { cursor: `${room.epoch}:${seq + events.length}`, events };
60
+ }
61
+
62
+ async function handle(req, res) {
63
+ if (closing) return json(res, 503, { error: 'relay_closing' });
64
+ const auth = crypto.createHash('sha256').update(req.headers.authorization || '').digest();
65
+ if (!crypto.timingSafeEqual(auth, expectedAuth)) return json(res, 401, { error: 'unauthorized' });
66
+ let url;
67
+ try { url = new URL(req.url, 'http://relay.local'); } catch (_) {
68
+ return json(res, 400, { error: 'invalid_url' });
69
+ }
70
+ const match = /^\/v1\/rooms\/([a-f0-9]{64})\/events$/.exec(url.pathname);
71
+ if (!match) return json(res, 404, { error: 'not_found' });
72
+ if (!['GET', 'POST'].includes(req.method)) return json(res, 405, { error: 'method_not_allowed' });
73
+ let room = rooms.get(match[1]);
74
+ if (!room) {
75
+ if (rooms.size >= 16) return json(res, 503, { error: 'relay_room_limit' });
76
+ room = { epoch: crypto.randomUUID(), seq: 0, events: [], ids: new Map(), bytes: 0, waiters: new Set(), touched: Date.now() };
77
+ rooms.set(match[1], room);
78
+ }
79
+ room.touched = Date.now();
80
+ if (req.method === 'GET') {
81
+ const cursor = url.searchParams.get('after');
82
+ const result = readEvents(room, cursor);
83
+ const wait = Math.min(25000, Math.max(0, Number(url.searchParams.get('wait_ms')) || 0));
84
+ if (result.error || result.events.length || cursor === null || !wait) {
85
+ return json(res, result.error ? 409 : 200, result);
86
+ }
87
+ if (room.waiters.size >= 32 || responses.size >= 128) return json(res, 429, { error: 'relay_reader_limit' });
88
+ const finish = () => {
89
+ cleanup();
90
+ const next = readEvents(room, cursor);
91
+ json(res, next.error ? 409 : 200, next);
92
+ };
93
+ const cleanup = () => {
94
+ clearTimeout(timer);
95
+ room.waiters.delete(finish);
96
+ responses.delete(res);
97
+ res.removeListener('close', cleanup);
98
+ };
99
+ const timer = setTimeout(finish, wait);
100
+ room.waiters.add(finish);
101
+ responses.add(res);
102
+ res.on('close', cleanup);
103
+ return;
104
+ }
105
+
106
+ if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
107
+ return json(res, 415, { error: 'json_required' });
108
+ }
109
+ const chunks = [];
110
+ let bytes = 0;
111
+ for await (const chunk of req) {
112
+ bytes += chunk.length;
113
+ if (bytes > MAX_BODY_BYTES) {
114
+ json(res, 413, { error: 'event_too_large' });
115
+ return;
116
+ }
117
+ chunks.push(chunk);
118
+ }
119
+ let event;
120
+ try { event = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch (_) {
121
+ return json(res, 400, { error: 'invalid_json' });
122
+ }
123
+ if (!event || typeof event.id !== 'string' || !/^[a-f0-9-]{36}$/.test(event.id) ||
124
+ typeof event.box !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(event.box) || event.box.length < 40) {
125
+ return json(res, 400, { error: 'invalid_event' });
126
+ }
127
+ prune(room);
128
+ const previous = room.ids.get(event.id);
129
+ if (previous) {
130
+ if (previous.box !== event.box) return json(res, 409, { error: 'event_id_conflict' });
131
+ return json(res, 200, { accepted: true, duplicate: true });
132
+ }
133
+ const stored = { id: event.id, box: event.box, seq: ++room.seq, ts: Date.now(), bytes };
134
+ room.events.push(stored);
135
+ room.ids.set(stored.id, stored);
136
+ room.bytes += bytes;
137
+ prune(room);
138
+ json(res, 202, { accepted: true });
139
+ for (const finish of Array.from(room.waiters)) finish();
140
+ }
141
+
142
+ const server = http.createServer((req, res) => {
143
+ handle(req, res).catch(() => {
144
+ if (!res.headersSent && !res.destroyed) json(res, 400, { error: 'invalid_request' });
145
+ else res.destroy();
146
+ });
147
+ });
148
+ server.on('connection', (socket) => {
149
+ sockets.add(socket);
150
+ socket.once('close', () => sockets.delete(socket));
151
+ });
152
+ server.requestTimeout = 30000;
153
+ server.headersTimeout = 10000;
154
+ server.maxConnections = 256;
155
+ const cleanup = setInterval(() => {
156
+ for (const [name, room] of rooms) {
157
+ prune(room);
158
+ if (!room.waiters.size && Date.now() - room.touched > RETENTION_MS) rooms.delete(name);
159
+ }
160
+ }, 30000);
161
+ cleanup.unref();
162
+ try {
163
+ await new Promise((resolve, reject) => {
164
+ server.once('error', reject);
165
+ server.listen(options.port === undefined ? 8790 : options.port, options.host || '127.0.0.1', resolve);
166
+ });
167
+ } catch (error) {
168
+ clearInterval(cleanup);
169
+ throw error;
170
+ }
171
+ return {
172
+ server,
173
+ port: server.address().port,
174
+ close() {
175
+ if (closePromise) return closePromise;
176
+ closing = true;
177
+ clearInterval(cleanup);
178
+ closePromise = new Promise((resolve, reject) => {
179
+ server.close((error) => error ? reject(error) : resolve());
180
+ for (const res of responses) res.destroy();
181
+ for (const socket of sockets) socket.destroy();
182
+ });
183
+ return closePromise;
184
+ }
185
+ };
186
+ }
187
+
188
+ if (require.main === module) {
189
+ if (process.argv.includes('--help')) {
190
+ console.log('Usage: vdon-mcp-relay\nEnvironment: VDON_RELAY_TOKEN (required), VDON_RELAY_HOST (127.0.0.1), VDON_RELAY_PORT (8790).\nUse an HTTPS reverse proxy for remote clients. See references/http-relay.md.');
191
+ } else {
192
+ const port = Number(process.env.VDON_RELAY_PORT || 8790);
193
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
194
+ console.error('VDON_RELAY_PORT must be between 1 and 65535');
195
+ process.exitCode = 1;
196
+ } else {
197
+ startRelayServer({ host: process.env.VDON_RELAY_HOST, port }).then((relay) => {
198
+ console.error(`Ninja MCP relay listening on port ${relay.port}`);
199
+ const stop = () => relay.close().catch(() => { process.exitCode = 1; });
200
+ process.once('SIGINT', stop);
201
+ process.once('SIGTERM', stop);
202
+ }).catch((error) => { console.error(error.message); process.exitCode = 1; });
203
+ }
204
+ }
205
+ }
206
+
207
+ module.exports = { startRelayServer };
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ // Copyright (c) 2026 Steve Seguin
3
+ // SPDX-License-Identifier: MIT
4
+
2
5
  'use strict';
3
6
 
4
7
  const { BridgeSession } = require('./lib/bridge-session');
8
+ const { validateRelayConfig } = require('./lib/http-relay-transport');
5
9
  const { McpStdioParser, writeMcpMessage } = require('./lib/mcp-stdio-parser');
6
10
  const {
7
11
  DEFAULT_TOOL_PROFILE,
@@ -24,7 +28,7 @@ const {
24
28
  const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2024-11-05'];
25
29
  const SERVER_INFO = {
26
30
  name: 'vdo-ninja-mcp',
27
- version: '0.4.0'
31
+ version: '0.4.2'
28
32
  };
29
33
  const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
30
34
 
@@ -32,11 +36,15 @@ function buildTools() {
32
36
  return [
33
37
  {
34
38
  name: 'vdo_connect',
35
- description: 'Start a VDO.Ninja data-channel session and optionally auto-view a target stream.',
39
+ description: 'Start a room session using WebRTC (default) or a configured encrypted HTTP relay.',
36
40
  inputSchema: {
37
41
  type: 'object',
38
42
  properties: {
39
43
  room: { type: 'string', description: 'Room name (alphanumeric/underscore recommended).' },
44
+ transport: { type: 'string', enum: ['webrtc', 'http'], description: 'Peer transport. HTTP requires a self-hosted relay; it works without WebRTC.' },
45
+ relay_url: { type: 'string', description: 'HTTPS relay origin; defaults to VDON_MCP_RELAY_URL. Loopback HTTP is allowed.' },
46
+ relay_token: { type: 'string', description: 'Relay access token; prefer VDON_MCP_RELAY_TOKEN in the server environment.' },
47
+ relay_key: { type: 'string', description: 'Shared room encryption key: 32 random bytes as 64 hex characters. Prefer VDON_MCP_RELAY_KEY.' },
40
48
  stream_id: { type: 'string', description: 'Local stream id. Randomized when omitted.' },
41
49
  target_stream_id: { type: 'string', description: 'Optional remote stream id to auto-view.' },
42
50
  host: { type: 'string', description: 'WebSocket endpoint. Defaults to wss://wss.vdo.ninja.' },
@@ -76,7 +84,7 @@ function buildTools() {
76
84
  },
77
85
  {
78
86
  name: 'vdo_send',
79
- description: 'Send one payload over an active session data channel.',
87
+ description: 'Send one payload over an active session. Success means locally accepted; HTTP relay receipts arrive through vdo_receive.',
80
88
  inputSchema: {
81
89
  type: 'object',
82
90
  properties: {
@@ -500,6 +508,38 @@ Options:
500
508
  `.trim();
501
509
  }
502
510
 
511
+ function isInteractiveTerminal(stream) {
512
+ return !!(stream && stream.isTTY);
513
+ }
514
+
515
+ function buildStartupBanner(server) {
516
+ const profile = server && server.toolProfile && server.toolProfile.name
517
+ ? server.toolProfile.name
518
+ : DEFAULT_TOOL_PROFILE;
519
+ return [
520
+ '[vdon-mcp] Server running (stdio) and waiting for MCP client input.',
521
+ `[vdon-mcp] Active tool profile: ${profile}`,
522
+ '[vdon-mcp] First tool call: {"name":"vdo_capabilities","arguments":{}}',
523
+ '[vdon-mcp] Stop with Ctrl+C'
524
+ ];
525
+ }
526
+
527
+ function maybeWriteStartupBanner(options, server) {
528
+ const env = options.env || process.env;
529
+ const disabled = toBool(env.VDON_MCP_NO_STARTUP_BANNER, false);
530
+ if (disabled) return false;
531
+
532
+ const input = options.input || process.stdin;
533
+ const output = options.output || process.stdout;
534
+ const errorOutput = options.errorOutput || process.stderr;
535
+
536
+ const interactive = isInteractiveTerminal(input) && isInteractiveTerminal(output) && isInteractiveTerminal(errorOutput);
537
+ if (!interactive) return false;
538
+
539
+ errorOutput.write(`${buildStartupBanner(server).join('\n')}\n`);
540
+ return true;
541
+ }
542
+
503
543
  class VdoMcpServer {
504
544
  constructor(options = {}) {
505
545
  this.protocolVersion = SUPPORTED_PROTOCOL_VERSIONS[0];
@@ -574,7 +614,8 @@ class VdoMcpServer {
574
614
  'join_token', 'join_token_secret', 'join_token_ttl_ms', 'enforce_join_token', 'allow_peer_stream_ids',
575
615
  'file_chunk_bytes', 'file_max_bytes', 'file_ack_timeout_ms', 'file_max_retries', 'require_session_mac',
576
616
  'file_spool_dir', 'file_spool_threshold_bytes', 'keep_spool_files',
577
- 'state_max_keys', 'state_max_snapshot_entries'
617
+ 'state_max_keys', 'state_max_snapshot_entries',
618
+ 'transport', 'relay_url', 'relay_token', 'relay_key'
578
619
  ],
579
620
  'vdo_connect'
580
621
  );
@@ -584,6 +625,22 @@ class VdoMcpServer {
584
625
  throw new Error('room is required');
585
626
  }
586
627
 
628
+ const transport = args.transport === undefined ? 'webrtc' : args.transport;
629
+ if (!['webrtc', 'http'].includes(transport)) throw new Error('invalid transport: use webrtc or http');
630
+ const relayConfig = {
631
+ relayUrl: args.relay_url === undefined ? process.env.VDON_MCP_RELAY_URL : args.relay_url,
632
+ relayToken: args.relay_token === undefined ? process.env.VDON_MCP_RELAY_TOKEN : args.relay_token,
633
+ relayKey: args.relay_key === undefined ? process.env.VDON_MCP_RELAY_KEY : args.relay_key
634
+ };
635
+ if (transport === 'http') {
636
+ validateRelayConfig(relayConfig);
637
+ if (args.host !== undefined || args.password !== undefined || args.force_turn !== undefined) {
638
+ throw new Error('host, password, and force_turn are WebRTC-only fields; use relay_url and relay_key for HTTP');
639
+ }
640
+ } else if (['relay_url', 'relay_token', 'relay_key'].some((key) => args[key] !== undefined)) {
641
+ throw new Error('relay options require transport: http');
642
+ }
643
+
587
644
  if (args.host !== undefined && typeof args.host !== 'string') {
588
645
  throw new Error('host must be a string');
589
646
  }
@@ -633,6 +690,8 @@ class VdoMcpServer {
633
690
  : this.defaultAllowPeerStreamIDs;
634
691
 
635
692
  return {
693
+ transport,
694
+ ...(transport === 'http' ? relayConfig : {}),
636
695
  host: typeof args.host === 'string' && args.host.trim() ? args.host : 'wss://wss.vdo.ninja',
637
696
  room,
638
697
  streamID,
@@ -749,7 +808,8 @@ class VdoMcpServer {
749
808
  session_id: sessionId,
750
809
  ...session.getStatus(),
751
810
  effective_config: {
752
- host: config.host,
811
+ transport: config.transport,
812
+ host: config.transport === 'http' ? config.relayUrl : config.host,
753
813
  room: config.room,
754
814
  stream_id: config.streamID,
755
815
  target_stream_id: config.targetStreamID,
@@ -791,7 +851,11 @@ class VdoMcpServer {
791
851
  return {
792
852
  ok,
793
853
  session_id: args.session_id,
794
- used_target: target || null
854
+ used_target: target || null,
855
+ ...(session.config.transport === 'http' ? {
856
+ message_id: ok ? session.sdk.lastMessageId : null,
857
+ delivery_stage: ok ? 'queued' : 'rejected'
858
+ } : {})
795
859
  };
796
860
  }
797
861
 
@@ -869,10 +933,21 @@ class VdoMcpServer {
869
933
  features: {
870
934
  session_messaging: true,
871
935
  file_transfer: hasFileTools,
872
- shared_state_sync: hasStateTools
936
+ shared_state_sync: hasStateTools,
937
+ live_audio_video: false
873
938
  },
874
939
  transport_truth: {
875
940
  type: 'webrtc_data_channel',
941
+ default_transport: 'webrtc',
942
+ supported_transports: ['webrtc', 'http'],
943
+ http_relay: {
944
+ independent_of_webrtc: true,
945
+ receive: 'long_poll',
946
+ encryption: 'AES-256-GCM with a shared room key',
947
+ retention: 'bounded in-memory replay; no offline inbox',
948
+ automatic_failover: false,
949
+ send_success: 'local_queue_acceptance; relay_receipt is not peer consumption'
950
+ },
876
951
  generic_tcp_tunnel: false,
877
952
  turn_relays_help_reachability: true,
878
953
  guaranteed_firewall_bypass: false,
@@ -1328,6 +1403,8 @@ async function runMcpStdioLoop(options = {}) {
1328
1403
  onExit
1329
1404
  });
1330
1405
 
1406
+ maybeWriteStartupBanner(options, server);
1407
+
1331
1408
  const parser = new McpStdioParser({ maxMessageBytes });
1332
1409
  let chain = Promise.resolve();
1333
1410
  let closed = false;
@@ -1421,5 +1498,7 @@ module.exports = {
1421
1498
  buildTools,
1422
1499
  makeToolResult,
1423
1500
  classifyToolError,
1424
- parseCliArgs
1501
+ parseCliArgs,
1502
+ buildStartupBanner,
1503
+ maybeWriteStartupBanner
1425
1504
  };
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Steve Seguin
3
+ // SPDX-License-Identifier: MIT
4
+ 'use strict';
5
+
6
+ const fs = require('node:fs');
7
+ const readline = require('node:readline');
8
+ const { loadConfig: loadBrowserConfig, makeSession } = require('./vdo-mcp-browser');
9
+ const { BrowserRpcWorker, BrowserRpcClient } = require('./lib/browser-rpc');
10
+ const { SsappController, RPC_OPTIONS, controlOrigin, validateCommand } = require('./lib/ssapp-worker');
11
+ const { McpStdioParser, writeMcpMessage } = require('./lib/mcp-stdio-parser');
12
+
13
+ const managementTool = (name, description, properties = {}, required = []) => ({ name, description,
14
+ inputSchema: { type: 'object', properties, required, additionalProperties: false } });
15
+ const MANAGEMENT = [
16
+ managementTool('ninja_ssapp_status', 'Discover the remote SSApp instance, versions, permissions and control owner. After app restart, list fresh targets and claim control again.'),
17
+ managementTool('ninja_ssapp_claim', 'Claim or renew control of this SSApp instance for this MCP connection. Other agents can observe. Reconcile uncertain outcomes before using reconciled: true.', {
18
+ ttl_ms: { type: 'integer', minimum: 1000, maximum: 120000 }, reconciled: { type: 'boolean' }
19
+ }),
20
+ managementTool('ninja_ssapp_release', 'Release control for another agent. An already executing action may finish.'),
21
+ managementTool('ninja_ssapp_result', 'Read a retained transport result by request_id. Unknown does not mean an action failed; inspect SSApp before retrying.', { request_id: { type: 'string' } }, ['request_id'])
22
+ ];
23
+
24
+ function loadConfig(file) {
25
+ const config = loadBrowserConfig(file);
26
+ if (config.backend !== 'ssapp') throw new Error('Use an SSApp configuration generated with vdon-mcp-ssapp --setup');
27
+ if (config.clients) {
28
+ if (!config.ssapp) throw new Error('Configure the local SSApp MCP command on the host');
29
+ validateCommand(config.ssapp);
30
+ controlOrigin(config.ssapp.controlUrl);
31
+ for (const value of Object.values(config.clients)) {
32
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== 'string' || !tool.startsWith('ssapp_')))) throw new Error('Client tools must be an array of ssapp_* tool names');
33
+ if (value.allowHostFiles !== undefined && typeof value.allowHostFiles !== 'boolean') throw new Error('allowHostFiles must be a boolean');
34
+ }
35
+ }
36
+ return config;
37
+ }
38
+
39
+ async function startWorker(config) {
40
+ if (!config.clients || config.backend !== 'ssapp') throw new Error('Use the SSApp worker.json on the host');
41
+ const session = makeSession(config, true);
42
+ const controller = new SsappController(config);
43
+ const callers = new Map();
44
+ const routes = () => session.sessions ? Array.from(session.sessions.values()) : [session];
45
+ const rpc = new BrowserRpcWorker(session, config, (...args) => controller.execute(...args), { ...RPC_OPTIONS,
46
+ onAuthenticated: (client, event) => {
47
+ const peer = routes().flatMap((route) => route.listSyncPeers()).find((peer) => event.uuid === peer.uuid || event.uuid.endsWith(':' + peer.uuid));
48
+ if (peer?.stream_id) callers.set(client, peer.stream_id);
49
+ } });
50
+ const disconnectCheck = setInterval(() => {
51
+ const owner = controller.lease?.owner;
52
+ const stream = callers.get(owner);
53
+ if (owner && stream && !routes().some((route) => route.listSyncPeers().some((peer) => peer.stream_id === stream && route.hasOpenDataChannel(peer.uuid)))) controller.revoke();
54
+ }, 1000);
55
+ disconnectCheck.unref();
56
+ let closing;
57
+ const close = () => closing || (closing = (async () => { clearInterval(disconnectCheck); rpc.close(); const stopping = controller.close(); await session.stop(); await stopping; })());
58
+ try { await controller.ensure(); await session.start(); } catch (failure) { await close(); throw failure; }
59
+ return { session, rpc, controller, close };
60
+ }
61
+
62
+ class SsappClient {
63
+ constructor(config, options = {}) {
64
+ this.config = config;
65
+ this.session = options.session || makeSession(config, false);
66
+ this.rpc = new BrowserRpcClient(this.session, config, RPC_OPTIONS);
67
+ this.discovery = null; this.lease = null; this.connecting = null;
68
+ }
69
+ async connect() {
70
+ if (!this.connecting) this.connecting = this.session.start().then(() => this.rpc.connect()).catch((failure) => { this.connecting = null; throw failure; });
71
+ await this.connecting;
72
+ }
73
+ async discover() {
74
+ await this.connect();
75
+ const value = await this.rpc.request('discover', {}, 60000);
76
+ if (value.error) throw new Error(value.error);
77
+ if (value.backend !== 'ssapp' || value.contract !== 1) throw new Error('Incompatible remote SSApp worker');
78
+ if (value.instance?.instanceId !== this.discovery?.instance?.instanceId) this.lease = null;
79
+ this.discovery = value;
80
+ return value;
81
+ }
82
+ async call(name, args = {}) {
83
+ if (!this.discovery) await this.discover();
84
+ if (name === 'ninja_ssapp_status') return this.discover();
85
+ const common = { instance: this.discovery.instance?.instanceId, lease: this.lease };
86
+ let response;
87
+ if (name === 'ninja_ssapp_claim') {
88
+ response = await this.rpc.request('claim', { ...args, ...common }, 60000);
89
+ if (response.lease) this.lease = response.lease;
90
+ } else if (name === 'ninja_ssapp_release') {
91
+ response = await this.rpc.request('release', common, 60000); this.lease = null;
92
+ } else if (name === 'ninja_ssapp_result') response = await this.rpc.request('result', args, 60000);
93
+ else response = await this.rpc.request('call', { ...common, name, arguments: args }, 60000);
94
+ if (['worker_restarted', 'fresh_app_discovery_required', 'valid_app_lease_required'].includes(response.error)) this.lease = null;
95
+ return response;
96
+ }
97
+ async close() {
98
+ if (this.lease) await this.rpc.request('release', { lease: this.lease }, 3000).catch(() => {});
99
+ this.lease = null; this.rpc.close(); await this.session.stop();
100
+ }
101
+ }
102
+
103
+ function mcpResult(response, discovery) {
104
+ const metadata = { backend: 'ssapp', request_id: response.request_id, route: response.route, instanceId: discovery?.instance?.instanceId };
105
+ if (Array.isArray(response.result?.content) && !response.error) return { ...response.result, _meta: { ...response.result._meta, ninja: metadata } };
106
+ if (!response.error) return { content: [{ type: 'text', text: JSON.stringify(response) }], _meta: { ninja: metadata } };
107
+ const structuredContent = { ok: false, ssappVersion: discovery?.instance?.ssappVersion || 'unknown', apiVersion: discovery?.instance?.apiVersion || 'unknown',
108
+ error: { code: response.error, message: response.message || response.error, ...(response.execution ? { execution: response.execution } : {}) } };
109
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent, _meta: { ninja: metadata } };
110
+ }
111
+
112
+ async function startClient(config, input = process.stdin, output = process.stdout) {
113
+ if (config.clients) throw new Error('Use an individual agent JSON file');
114
+ const client = new SsappClient(config);
115
+ const parser = new McpStdioParser({ maxMessageBytes: 65536 });
116
+ const active = new Set(); let closed = false, closing;
117
+ const dispatch = async (message, mode) => {
118
+ if (closed || !message || message.jsonrpc !== '2.0' || message.id === undefined) return;
119
+ const reply = (value) => writeMcpMessage(output, { jsonrpc: '2.0', id: message.id, ...value }, mode);
120
+ if (active.size >= 12) return reply({ error: { code: -32000, message: 'Too many pending requests' } });
121
+ try {
122
+ if (message.method === 'initialize') {
123
+ const discovery = await client.discover();
124
+ return reply({ result: { protocolVersion: '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: discovery.serverInfo,
125
+ instructions: 'These SSApp tools operate the remote host. Call ninja_ssapp_status and list targets; claim control before mutations and release after the task. Keep ssapp_* arguments unchanged. Host file paths refer to that computer. ' + discovery.instructions,
126
+ _meta: { ninja: { backend: 'ssapp', instance: discovery.instance } } } });
127
+ }
128
+ if (message.method === 'ping') return reply({ result: {} });
129
+ if (message.method === 'tools/list') {
130
+ const discovery = await client.discover();
131
+ return reply({ result: { ...discovery.catalog, tools: [...MANAGEMENT, ...discovery.catalog.tools] } });
132
+ }
133
+ if (message.method !== 'tools/call') return reply({ error: { code: -32601, message: 'Method not found' } });
134
+ const params = message.params || {};
135
+ if (typeof params.name !== 'string' || params.arguments && (typeof params.arguments !== 'object' || Array.isArray(params.arguments))) return reply({ error: { code: -32602, message: 'Invalid tool arguments' } });
136
+ const response = await client.call(params.name, params.arguments || {});
137
+ reply({ result: mcpResult(response, client.discovery) });
138
+ } catch (failure) {
139
+ if (message.method !== 'tools/call') return reply({ error: { code: -32000, message: failure.message } });
140
+ reply({ result: mcpResult({ error: failure.code || 'ninja_ssapp_error', message: failure.message }, client.discovery) });
141
+ }
142
+ };
143
+ parser.on('message', ({ message, mode }) => {
144
+ const pending = dispatch(message, mode); active.add(pending); pending.finally(() => active.delete(pending));
145
+ });
146
+ parser.on('error', () => writeMcpMessage(output, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Invalid MCP request' } }, parser.mode));
147
+ const close = () => closing || (closing = (async () => {
148
+ closed = true;
149
+ const releasing = client.lease ? client.rpc.request('release', { lease: client.lease }, 3000).catch(() => {}) : Promise.resolve();
150
+ await Promise.allSettled([...active, releasing]); await client.close();
151
+ })());
152
+ input.on('data', (chunk) => { parser.push(chunk); if (parser.buffer.length > 65536) { parser.buffer = Buffer.alloc(0); close().catch(() => {}); } });
153
+ return { client, close };
154
+ }
155
+
156
+ async function main() {
157
+ const args = process.argv.slice(2);
158
+ if (args.includes('--help')) {
159
+ console.log('vdon-mcp-ssapp --setup <private-directory> --adapter-config <copied-mcp.json> [--transport webrtc|http|auto] [--relay-config <relay.json>]\nvdon-mcp-ssapp --worker --config <worker.json>\nvdon-mcp-ssapp --config <agent1.json>\nvdon-mcp-ssapp --doctor --config <agent1.json>'); return;
160
+ }
161
+ if (args[0] === '--setup') {
162
+ const directory = args[1]; const options = { backend: 'ssapp' }; let adapterFile;
163
+ if (!directory || directory.startsWith('--')) throw new Error('Choose a private setup directory');
164
+ for (let i = 2; i < args.length; i += 2) {
165
+ if (!args[i + 1]) throw new Error(`Missing value for ${args[i]}`);
166
+ if (args[i] === '--transport') options.transport = args[i + 1];
167
+ else if (args[i] === '--relay-config') options.relayConfig = args[i + 1];
168
+ else if (args[i] === '--adapter-config') adapterFile = args[i + 1];
169
+ else throw new Error(`Unknown option ${args[i]}`);
170
+ }
171
+ if (!adapterFile) throw new Error('Save SSApp\'s copied MCP setup to a private file and pass --adapter-config');
172
+ const adapter = JSON.parse(fs.readFileSync(adapterFile, 'utf8'));
173
+ const entries = adapter.mcpServers ? Object.values(adapter.mcpServers) : [adapter];
174
+ if (entries.length !== 1) throw new Error('Adapter configuration must contain exactly one SSApp MCP command');
175
+ options.ssapp = { ...entries[0], controlUrl: controlOrigin(entries[0].env?.SSAPP_CONTROL_URL) };
176
+ validateCommand(options.ssapp);
177
+ const result = require('./lib/browser-setup').createSetup(directory, options);
178
+ console.error(`Created private SSApp credentials in ${result.directory}. Start the host with --worker --config worker.json; give each agent only its own JSON file.`); return;
179
+ }
180
+ const worker = args[0] === '--worker', doctor = args[0] === '--doctor';
181
+ if (worker || doctor) args.shift();
182
+ if (args.length !== 2 || args[0] !== '--config') throw new Error('Use --config <file>');
183
+ const config = loadConfig(args[1]);
184
+ if (doctor) {
185
+ if (config.clients) throw new Error('Use an individual agent JSON file for --doctor');
186
+ const client = new SsappClient(config);
187
+ let connected = false;
188
+ try {
189
+ const d = await client.discover(); connected = !!d.instance;
190
+ console.log(JSON.stringify({ ok: connected, backend: d.backend, app: d.instance, mcp: d.serverInfo, role: d.role, tools: d.catalog.tools.length, route: d.route,
191
+ ...(connected ? {} : { action: 'Start SSApp with API 1.4.1 or newer and enable Local AI / Automation.' }) }, null, 2));
192
+ }
193
+ finally { await client.close(); }
194
+ process.exit(connected ? 0 : 1);
195
+ }
196
+ const service = worker ? await startWorker(config) : await startClient(config);
197
+ const shutdown = () => service.close().then(() => process.stdout.write('', () => process.exit(0)), (failure) => { console.error(failure.message); process.exit(1); });
198
+ process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
199
+ if (worker) {
200
+ console.error('SSApp bridge running. Enter revoke to release control, or stop to disconnect the bridge.');
201
+ const terminal = readline.createInterface({ input: process.stdin });
202
+ terminal.on('line', (line) => { if (line.trim() === 'revoke') { service.controller.revoke(); console.error('Control revoked. An already executing action may finish.'); } if (line.trim() === 'stop') { terminal.close(); shutdown(); } });
203
+ terminal.on('SIGINT', shutdown);
204
+ } else { process.stdin.once('end', shutdown); process.stdin.once('close', shutdown); }
205
+ }
206
+
207
+ if (require.main === module) main().catch((failure) => { console.error(failure.message); process.exitCode = 1; });
208
+ module.exports = { MANAGEMENT, loadConfig, startWorker, SsappClient, startClient, mcpResult };
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // Copyright (c) 2026 Steve Seguin
3
+ // SPDX-License-Identifier: MIT
4
+
2
5
  'use strict';
3
6
 
4
7
  const http = require('node:http');
@@ -101,8 +104,17 @@ async function startStreamableHttpServer(options = {}) {
101
104
  const runInQueue = createQueueRunner();
102
105
 
103
106
  const httpServer = http.createServer(async (req, res) => {
104
- const reqUrl = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
105
107
  const corsHeaders = withCorsHeaders({}, allowOrigin);
108
+ let reqUrl;
109
+ try {
110
+ reqUrl = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
111
+ } catch (error) {
112
+ jsonResponse(res, 400, {
113
+ ok: false,
114
+ error: 'invalid_url'
115
+ }, corsHeaders);
116
+ return;
117
+ }
106
118
 
107
119
  if (req.method === 'OPTIONS') {
108
120
  res.writeHead(204, corsHeaders);
@@ -209,14 +221,15 @@ async function startStreamableHttpServer(options = {}) {
209
221
  endpoint: endpointPath
210
222
  });
211
223
 
224
+ let closePromise;
212
225
  return {
213
226
  server: httpServer,
214
227
  mcpServer: server,
215
228
  baseUrl,
216
229
  endpointUrl: `${baseUrl}${endpointPath}`,
217
- close: async () => {
218
- await server.shutdownAll();
219
- await new Promise((resolve, reject) => {
230
+ close: () => {
231
+ if (closePromise) return closePromise;
232
+ closePromise = new Promise((resolve, reject) => {
220
233
  httpServer.close((error) => {
221
234
  if (error && error.code !== 'ERR_SERVER_NOT_RUNNING' && error.code !== 'EPERM') {
222
235
  reject(error);
@@ -224,7 +237,13 @@ async function startStreamableHttpServer(options = {}) {
224
237
  }
225
238
  resolve();
226
239
  });
240
+ }).finally(() => {
241
+ // Stop admission and drain HTTP before cleanup. Disconnected clients
242
+ // can leave dispatches running after their sockets close, so queue
243
+ // cleanup behind all accepted MCP work as well.
244
+ return runInQueue(() => server.shutdownAll());
227
245
  });
246
+ return closePromise;
228
247
  }
229
248
  };
230
249
  }