@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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/SKILL.md +8 -6
- package/assets/browser-agent/audio-worklet.js +17 -0
- package/assets/browser-agent/client.js +124 -0
- package/assets/browser-agent/index.html +50 -0
- package/assets/cloudflare/worker-proxy.js +3 -0
- package/docs/.nojekyll +0 -0
- package/docs/404.html +28 -0
- package/docs/advanced-diagnostics.html +101 -0
- package/docs/assets/apple-touch-icon.png +0 -0
- package/docs/assets/favicon.svg +7 -0
- package/docs/assets/how-it-works.svg +62 -0
- package/docs/assets/icon-192.png +0 -0
- package/docs/assets/icon-512.png +0 -0
- package/docs/assets/icon-maskable-512.png +0 -0
- package/docs/assets/icons.svg +42 -0
- package/docs/assets/logo.svg +7 -0
- package/docs/assets/og-image.png +0 -0
- package/docs/assets/site.css +735 -0
- package/docs/assets/site.js +289 -0
- package/docs/browser-agent.html +122 -0
- package/docs/browser-worker.html +165 -0
- package/docs/client-config-examples.html +254 -0
- package/docs/direction.html +175 -0
- package/docs/favicon.ico +0 -0
- package/docs/guide.html +872 -0
- package/docs/http-relay.html +154 -0
- package/docs/index.html +834 -0
- package/docs/mcp-tool-contract.html +226 -0
- package/docs/quickstart-and-compat.html +222 -0
- package/docs/site.webmanifest +16 -0
- package/docs/sitemap.xml +63 -0
- package/docs/ssapp-integration.html +594 -0
- package/docs/vdo-ninja-p2p-data-notes.html +225 -0
- package/package.json +67 -11
- package/references/advanced-diagnostics.md +6 -4
- package/references/browser-agent.md +53 -0
- package/references/browser-worker.md +142 -0
- package/references/client-config-examples.md +253 -0
- package/references/direction.md +88 -0
- package/references/http-relay.md +107 -0
- package/references/mcp-tool-contract.md +16 -6
- package/references/quickstart-and-compat.md +18 -6
- package/references/ssapp-integration.md +371 -0
- package/references/vdo-ninja-p2p-data-notes.md +3 -1
- package/scripts/demo-file-workflow.js +25 -6
- package/scripts/demo-message-workflow.js +25 -6
- package/scripts/install-mcp.js +55 -4
- package/scripts/lib/bridge-session.js +143 -26
- package/scripts/lib/bridge-utils.js +3 -0
- package/scripts/lib/browser-agent.js +255 -0
- package/scripts/lib/browser-media.js +79 -0
- package/scripts/lib/browser-routes.js +119 -0
- package/scripts/lib/browser-rpc.js +214 -0
- package/scripts/lib/browser-setup.js +133 -0
- package/scripts/lib/browser-worker.js +216 -0
- package/scripts/lib/fake-network-sdk.js +3 -0
- package/scripts/lib/http-relay-transport.js +294 -0
- package/scripts/lib/load-vdo-sdk.js +3 -0
- package/scripts/lib/mcp-stdio-parser.js +3 -0
- package/scripts/lib/ssapp-worker.js +198 -0
- package/scripts/lib/tool-profiles.js +3 -0
- package/scripts/vdo-data-bridge.js +3 -0
- package/scripts/vdo-mcp-browser-agent.js +132 -0
- package/scripts/vdo-mcp-browser.js +218 -0
- package/scripts/vdo-mcp-relay.js +207 -0
- package/scripts/vdo-mcp-server.js +87 -8
- package/scripts/vdo-mcp-ssapp.js +208 -0
- package/scripts/vdo-mcp-streamable-http.js +23 -4
- package/server.json +26 -10
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright (c) 2026 Steve Seguin
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
|
|
8
|
+
// Capture only the worker's initial tab. The publisher lives in another browser
|
|
9
|
+
// context and is excluded from remote tools; no desktop or microphone capture.
|
|
10
|
+
async function startBrowserMedia(browser, target, title, config = {}) {
|
|
11
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
12
|
+
const server = http.createServer((req, res) => {
|
|
13
|
+
if (req.url !== `/${token}`) { res.writeHead(404); res.end(); return; }
|
|
14
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
15
|
+
res.end('<!doctype html><title>Ninja browser media publisher</title>');
|
|
16
|
+
});
|
|
17
|
+
let context, page, previousTitle;
|
|
18
|
+
const credentials = {
|
|
19
|
+
room: `media_${crypto.randomBytes(12).toString('hex')}`,
|
|
20
|
+
streamID: `chrome_${crypto.randomBytes(12).toString('hex')}`,
|
|
21
|
+
password: crypto.randomBytes(24).toString('hex')
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
25
|
+
context = await browser.createBrowserContext();
|
|
26
|
+
previousTitle = await target.title();
|
|
27
|
+
await target.evaluate((value) => { document.title = value; }, title);
|
|
28
|
+
page = await context.newPage();
|
|
29
|
+
await page.goto(`http://127.0.0.1:${server.address().port}/${token}`);
|
|
30
|
+
// Close the bootstrap listener immediately. No web control endpoint remains.
|
|
31
|
+
await new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); });
|
|
32
|
+
await page.addScriptTag({ path: require.resolve('@vdoninja/sdk/browser') });
|
|
33
|
+
await page.evaluate(async (settings) => {
|
|
34
|
+
const stream = await Promise.race([
|
|
35
|
+
navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 15, width: { max: 1280 }, height: { max: 720 } }, audio: true, selfBrowserSurface: 'exclude' }),
|
|
36
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Tab capture timed out')), 15000))
|
|
37
|
+
]);
|
|
38
|
+
if (stream.getVideoTracks().some((track) => track.getSettings().displaySurface !== 'browser')) {
|
|
39
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
40
|
+
throw new Error('Expected tab capture');
|
|
41
|
+
}
|
|
42
|
+
window.ninjaCapture = stream;
|
|
43
|
+
window.ninjaMedia = new VDONinjaSDK({ password: settings.password, ...(settings.host ? { host: settings.host } : {}) });
|
|
44
|
+
await Promise.race([
|
|
45
|
+
(async () => {
|
|
46
|
+
await window.ninjaMedia.connect();
|
|
47
|
+
await window.ninjaMedia.publish(stream, { room: settings.room, streamID: settings.streamID, password: settings.password, label: 'Ninja browser', media: { video: { maxBitrate: 1500000 }, audio: { maxBitrate: 64000 } } });
|
|
48
|
+
})(),
|
|
49
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Media signaling timed out')), 15000))
|
|
50
|
+
]);
|
|
51
|
+
}, { ...credentials, host: config.host });
|
|
52
|
+
await target.evaluate((value) => { document.title = value; }, previousTitle);
|
|
53
|
+
const viewer = new URL('https://vdo.ninja/');
|
|
54
|
+
viewer.searchParams.set('view', credentials.streamID);
|
|
55
|
+
viewer.searchParams.set('room', credentials.room);
|
|
56
|
+
viewer.searchParams.set('password', credentials.password);
|
|
57
|
+
viewer.searchParams.set('cleanoutput', '');
|
|
58
|
+
return {
|
|
59
|
+
page,
|
|
60
|
+
status: async () => ({ enabled: true, transport: 'webrtc', stream_id: credentials.streamID, room: credentials.room,
|
|
61
|
+
password: credentials.password, ...(config.host ? { signaling_host: config.host } : { viewer_url: viewer.href }),
|
|
62
|
+
...await page.evaluate(() => ({ connected: !!window.ninjaMedia.state.connected,
|
|
63
|
+
tracks: window.ninjaCapture.getTracks().map((track) => ({ kind: track.kind, state: track.readyState, muted: track.muted })) })) }),
|
|
64
|
+
close: async () => {
|
|
65
|
+
try { await page.evaluate(async () => { window.ninjaCapture.getTracks().forEach((track) => track.stop()); await window.ninjaMedia.disconnect(); }); } catch (_) { /* Browser may already be closed. */ }
|
|
66
|
+
await context.close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
server.close();
|
|
71
|
+
server.closeAllConnections();
|
|
72
|
+
if (context) await context.close();
|
|
73
|
+
throw error;
|
|
74
|
+
} finally {
|
|
75
|
+
if (previousTitle !== undefined) await target.evaluate((value) => { document.title = value; }, previousTitle).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { startBrowserMedia };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Copyright (c) 2026 Steve Seguin
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const EventEmitter = require('node:events');
|
|
6
|
+
const { BridgeSession } = require('./bridge-session');
|
|
7
|
+
|
|
8
|
+
// One browser RPC worker owns authentication, leases and deduplication above
|
|
9
|
+
// both routes. Switching routes never creates a second execution authority.
|
|
10
|
+
class BrowserRoutes extends EventEmitter {
|
|
11
|
+
constructor(config, options = {}) {
|
|
12
|
+
super();
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.sessions = new Map();
|
|
15
|
+
this.requests = new Map();
|
|
16
|
+
this.lastReplyRoute = null;
|
|
17
|
+
this.preferHttpUntil = 0;
|
|
18
|
+
this.running = false;
|
|
19
|
+
this.probing = false;
|
|
20
|
+
this.createSession = options.createSession || ((value) => new BridgeSession(value));
|
|
21
|
+
for (const transport of ['webrtc', 'http']) {
|
|
22
|
+
const session = this.createSession({ ...config, transport });
|
|
23
|
+
const listener = (event) => this.emit('event', { ...event, route: transport,
|
|
24
|
+
...(event.uuid ? { uuid: `${transport}:${event.uuid}` } : {}) });
|
|
25
|
+
session.on('event', listener);
|
|
26
|
+
this.sessions.set(transport, session);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async start() {
|
|
31
|
+
if (this.running) return;
|
|
32
|
+
this.running = true;
|
|
33
|
+
this.probeTimer = setInterval(() => this.retryView(), 5000);
|
|
34
|
+
this.probeTimer.unref();
|
|
35
|
+
for (const [route, session] of this.sessions) {
|
|
36
|
+
const pending = session.start().catch(() => {});
|
|
37
|
+
if (route === 'http') await pending;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
available(route, target) {
|
|
42
|
+
return !!this.peer(route, target);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
peer(route, target) {
|
|
46
|
+
const session = this.sessions.get(route);
|
|
47
|
+
return session.listSyncPeers()
|
|
48
|
+
.filter((peer) => peer.stream_id === target && session.hasOpenDataChannel(peer.uuid))
|
|
49
|
+
.sort((a, b) => String(b.last_seen_at).localeCompare(String(a.last_seen_at)))[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
retryView() {
|
|
53
|
+
const target = this.config.targetStreamID;
|
|
54
|
+
const rtc = this.sessions.get('webrtc');
|
|
55
|
+
if (!this.running || !target || this.probing || rtc.connecting || rtc.state !== 'connected' ||
|
|
56
|
+
!rtc.sdk || this.available('webrtc', target)) return;
|
|
57
|
+
// The worker may return with a new peer UUID while signaling stays connected.
|
|
58
|
+
// Refresh only this browser client's intended view; HTTP continues meanwhile.
|
|
59
|
+
this.probing = true;
|
|
60
|
+
rtc.sdk.view(target, { audio: false, video: false }).catch(() => {}).finally(() => { this.probing = false; });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sendRequest(packet, target, { id, attempt }) {
|
|
64
|
+
if (!this.running) return false;
|
|
65
|
+
const previous = this.requests.get(id);
|
|
66
|
+
const preferred = Date.now() < this.preferHttpUntil ? 'http' : 'webrtc';
|
|
67
|
+
let choices = [preferred, preferred === 'webrtc' ? 'http' : 'webrtc'];
|
|
68
|
+
if (attempt > 0 && previous) {
|
|
69
|
+
choices = [previous === 'webrtc' ? 'http' : 'webrtc', previous];
|
|
70
|
+
if (previous === 'webrtc') this.preferHttpUntil = Date.now() + 30000;
|
|
71
|
+
}
|
|
72
|
+
for (const route of choices) {
|
|
73
|
+
const peer = this.peer(route, target);
|
|
74
|
+
if (!peer) continue;
|
|
75
|
+
if (this.sessions.get(route).send(packet, { uuid: peer.uuid })) {
|
|
76
|
+
if (this.requests.size >= 128) this.requests.delete(this.requests.keys().next().value);
|
|
77
|
+
this.requests.set(id, route);
|
|
78
|
+
this.emit('event', { type: 'browser_route_sent', route, request_id: id, stage: 'queued' });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
markResponse(id, route) {
|
|
86
|
+
this.lastReplyRoute = route;
|
|
87
|
+
this.requests.delete(id);
|
|
88
|
+
if (route === 'webrtc') this.preferHttpUntil = 0;
|
|
89
|
+
this.emit('event', { type: 'browser_route_reply', route, request_id: id, stage: 'peer_response' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
finishRequest(id) { this.requests.delete(id); }
|
|
93
|
+
|
|
94
|
+
send(packet, target) {
|
|
95
|
+
if (!this.running) return false;
|
|
96
|
+
if (typeof target === 'string') {
|
|
97
|
+
const colon = target.indexOf(':');
|
|
98
|
+
const session = this.sessions.get(target.slice(0, colon));
|
|
99
|
+
if (session) return session.send(packet, { uuid: target.slice(colon + 1) });
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getStatus() {
|
|
105
|
+
return { transport: 'auto', last_reply_route: this.lastReplyRoute,
|
|
106
|
+
routes: Array.from(this.sessions, ([transport, session]) => ({ transport,
|
|
107
|
+
state: session.state, peers: session.listSyncPeers().filter((peer) => session.hasOpenDataChannel(peer.uuid)).length,
|
|
108
|
+
error: session.lastError || null })) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stop() {
|
|
112
|
+
this.running = false;
|
|
113
|
+
clearInterval(this.probeTimer);
|
|
114
|
+
this.requests.clear();
|
|
115
|
+
await Promise.allSettled(Array.from(this.sessions.values(), (session) => session.stop()));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { BrowserRoutes };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright (c) 2026 Steve Seguin
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const MAGIC = 'ninja_browser_v1';
|
|
7
|
+
const MAX_RESULT = 2 * 1024 * 1024;
|
|
8
|
+
const PART_BYTES = 24000;
|
|
9
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
function keyBytes(key) {
|
|
12
|
+
if (typeof key !== 'string' || !/^[a-f0-9]{64}$/i.test(key)) throw new Error('Browser keys must be 32 random bytes encoded as 64 hex characters');
|
|
13
|
+
return Buffer.from(key, 'hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Independent, per-client encryption: another room member cannot read or forge browser operations.
|
|
17
|
+
function seal(key, worker, client, direction, value, magic = MAGIC) {
|
|
18
|
+
const iv = crypto.randomBytes(12);
|
|
19
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(key), iv);
|
|
20
|
+
cipher.setAAD(Buffer.from(JSON.stringify([magic, worker, client, direction])));
|
|
21
|
+
const bytes = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
|
|
22
|
+
return { browser: magic, worker, client, direction, box: Buffer.concat([iv, cipher.getAuthTag(), bytes]).toString('base64') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function open(key, packet, magic = MAGIC) {
|
|
26
|
+
if (!packet || packet.browser !== magic || typeof packet.box !== 'string' || packet.box.length > 65000) throw new Error('Invalid browser packet');
|
|
27
|
+
const bytes = Buffer.from(packet.box, 'base64');
|
|
28
|
+
if (bytes.length < 29) throw new Error('Invalid browser packet');
|
|
29
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes(key), bytes.subarray(0, 12));
|
|
30
|
+
decipher.setAAD(Buffer.from(JSON.stringify([magic, packet.worker, packet.client, packet.direction])));
|
|
31
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
32
|
+
return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class BrowserRpcWorker {
|
|
36
|
+
constructor(session, config, execute, options = {}) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.magic = options.magic || MAGIC;
|
|
39
|
+
this.maxResult = options.maxResult || MAX_RESULT;
|
|
40
|
+
this.session = session;
|
|
41
|
+
this.worker = config.worker;
|
|
42
|
+
this.clients = config.clients;
|
|
43
|
+
this.execute = execute;
|
|
44
|
+
this.boot = crypto.randomUUID();
|
|
45
|
+
this.records = new Map();
|
|
46
|
+
this.cacheBytes = 0;
|
|
47
|
+
this.queued = 0;
|
|
48
|
+
this.chain = Promise.resolve();
|
|
49
|
+
this.closed = false;
|
|
50
|
+
this.listener = (event) => { this.receive(event).catch(() => {}); };
|
|
51
|
+
session.on('event', this.listener);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async receive(event) {
|
|
55
|
+
const packet = event.data;
|
|
56
|
+
if (this.closed || event.type !== 'data_received' || !event.uuid || !packet || packet.browser !== this.magic || packet.worker !== this.worker || packet.direction !== 'request') return;
|
|
57
|
+
if (!Object.hasOwn(this.clients, packet.client)) return;
|
|
58
|
+
const identity = this.clients[packet.client];
|
|
59
|
+
let request;
|
|
60
|
+
try { request = open(identity.key, packet, this.magic); } catch (_) { return; }
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (!request || typeof request.id !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(request.id) || !Number.isSafeInteger(request.deadline) || request.deadline < now || request.deadline > now + 65000) return;
|
|
63
|
+
if (request.op !== 'hello' && request.boot !== this.boot) {
|
|
64
|
+
await this.reply(packet.client, event.uuid, request.id, { error: 'worker_restarted', boot: this.boot });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (this.options.onAuthenticated) this.options.onAuthenticated(packet.client, event);
|
|
68
|
+
for (const [id, record] of this.records) {
|
|
69
|
+
if (record.until < now && record.result) { this.cacheBytes -= record.bytes; this.records.delete(id); }
|
|
70
|
+
}
|
|
71
|
+
const id = `${packet.client}:${request.id}`;
|
|
72
|
+
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
|
73
|
+
const previous = this.records.get(id);
|
|
74
|
+
if (previous) {
|
|
75
|
+
if (previous.fingerprint !== fingerprint) return this.reply(packet.client, event.uuid, request.id, { error: 'request_id_conflict' });
|
|
76
|
+
if (previous.result && !previous.sending) {
|
|
77
|
+
previous.sending = true;
|
|
78
|
+
try { await this.reply(packet.client, event.uuid, request.id, previous.result); } finally { previous.sending = false; }
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.queued >= 16 || this.records.size >= 1024 || this.cacheBytes > 30 * 1024 * 1024) {
|
|
83
|
+
// Leave the request pending until capacity returns or its deadline expires.
|
|
84
|
+
// Do not report rejection while a replay could still be admitted later.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const record = { fingerprint, until: request.deadline + 120000, result: null, bytes: 0, sending: false };
|
|
88
|
+
this.records.set(id, record);
|
|
89
|
+
this.queued++;
|
|
90
|
+
const run = async () => {
|
|
91
|
+
let result;
|
|
92
|
+
try {
|
|
93
|
+
if (this.closed || Date.now() > request.deadline) throw new Error('request_expired');
|
|
94
|
+
if (request.op === 'hello') result = { boot: this.boot, role: identity.role, worker: this.worker };
|
|
95
|
+
else if (request.op === 'result') {
|
|
96
|
+
const found = this.records.get(`${packet.client}:${request.args && request.args.request_id}`);
|
|
97
|
+
result = found && found.result ? { status: 'completed', result: found.result } : { status: 'unknown' };
|
|
98
|
+
} else result = await this.execute(packet.client, identity.role, request.op, request.args || {}, { ...request, peer: event.uuid });
|
|
99
|
+
} catch (error) { result = this.options.errorCodes ? { error: error.code || error.message, message: error.message } : { error: error.message }; }
|
|
100
|
+
if (Buffer.byteLength(JSON.stringify(result)) > this.maxResult) result = { error: 'result_too_large', execution: 'completed' };
|
|
101
|
+
record.result = result;
|
|
102
|
+
record.bytes = Buffer.byteLength(JSON.stringify(result));
|
|
103
|
+
this.cacheBytes += record.bytes;
|
|
104
|
+
this.queued--;
|
|
105
|
+
record.sending = true;
|
|
106
|
+
try { await this.reply(packet.client, event.uuid, request.id, result); } finally { record.sending = false; }
|
|
107
|
+
};
|
|
108
|
+
if (this.options.parallel) run().catch(() => {});
|
|
109
|
+
else this.chain = this.chain.then(run, run).catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async reply(client, uuid, id, result) {
|
|
113
|
+
const bytes = Buffer.from(JSON.stringify(result));
|
|
114
|
+
const count = Math.ceil(bytes.length / PART_BYTES);
|
|
115
|
+
for (let part = 0; part < count && !this.closed; part++) {
|
|
116
|
+
const packet = seal(this.clients[client].key, this.worker, client, 'response', {
|
|
117
|
+
id, boot: this.boot, part, count, data: bytes.subarray(part * PART_BYTES, (part + 1) * PART_BYTES).toString('base64')
|
|
118
|
+
}, this.magic);
|
|
119
|
+
let sent = this.session.send(packet, uuid);
|
|
120
|
+
if (!sent && this.options.backpressure) {
|
|
121
|
+
const until = Date.now() + 1000;
|
|
122
|
+
while (!sent && !this.closed && Date.now() < until) {
|
|
123
|
+
await delay(50);
|
|
124
|
+
sent = this.session.send(packet, uuid);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!sent) return;
|
|
128
|
+
// Bound queued bytes on HTTP and avoid monopolizing a data channel.
|
|
129
|
+
if (count > 1) await delay(12);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
close() { this.closed = true; this.session.off('event', this.listener); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class BrowserRpcClient {
|
|
137
|
+
constructor(session, config, options = {}) {
|
|
138
|
+
this.options = options;
|
|
139
|
+
this.magic = options.magic || MAGIC;
|
|
140
|
+
this.maxResult = options.maxResult || MAX_RESULT;
|
|
141
|
+
keyBytes(config.key);
|
|
142
|
+
this.session = session;
|
|
143
|
+
this.config = config;
|
|
144
|
+
this.boot = null;
|
|
145
|
+
this.pending = new Map();
|
|
146
|
+
this.listener = (event) => this.receive(event);
|
|
147
|
+
session.on('event', this.listener);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
receive(event) {
|
|
151
|
+
const p = event.data;
|
|
152
|
+
if (event.type !== 'data_received' || !p || p.browser !== this.magic || p.worker !== this.config.worker || p.client !== this.config.client || p.direction !== 'response') return;
|
|
153
|
+
let value;
|
|
154
|
+
try { value = open(this.config.key, p, this.magic); } catch (_) { return; }
|
|
155
|
+
const pending = this.pending.get(value.id);
|
|
156
|
+
if (!pending || !Number.isInteger(value.count) || value.count < 1 || value.count > Math.ceil(this.maxResult / PART_BYTES) || !Number.isInteger(value.part) || value.part < 0 || value.part >= value.count || typeof value.data !== 'string' || value.data.length > 32000) return;
|
|
157
|
+
if (pending.count && (pending.count !== value.count || pending.boot !== value.boot)) return;
|
|
158
|
+
pending.count = value.count;
|
|
159
|
+
pending.boot = value.boot;
|
|
160
|
+
pending.parts.set(value.part, Buffer.from(value.data, 'base64'));
|
|
161
|
+
if (pending.parts.size === value.count) {
|
|
162
|
+
try {
|
|
163
|
+
const bytes = Buffer.concat(Array.from({ length: value.count }, (_, i) => pending.parts.get(i)));
|
|
164
|
+
if (bytes.length > this.maxResult) return;
|
|
165
|
+
const result = JSON.parse(bytes.toString('utf8'));
|
|
166
|
+
pending.route = event.route || this.session.config.transport;
|
|
167
|
+
if (this.session.markResponse) this.session.markResponse(value.id, event.route);
|
|
168
|
+
pending.resolve(result);
|
|
169
|
+
} catch (_) { /* An incomplete or invalid result is never treated as success. */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async request(op, args = {}, timeoutMs = 30000) {
|
|
174
|
+
if (this.pending.size >= 16) throw new Error('Too many pending browser requests');
|
|
175
|
+
if (op !== 'hello' && !this.boot) await this.connect();
|
|
176
|
+
const id = crypto.randomUUID();
|
|
177
|
+
const request = { id, boot: this.boot, deadline: Date.now() + timeoutMs, op, args };
|
|
178
|
+
const packet = seal(this.config.key, this.config.worker, this.config.client, 'request', request, this.magic);
|
|
179
|
+
if (packet.box.length > 65000) throw new Error('Browser request too large');
|
|
180
|
+
let timer, retry;
|
|
181
|
+
try {
|
|
182
|
+
const result = await new Promise((resolve) => {
|
|
183
|
+
this.pending.set(id, { resolve, parts: new Map() });
|
|
184
|
+
let attempt = 0;
|
|
185
|
+
const send = () => {
|
|
186
|
+
if (this.session.sendRequest) return this.session.sendRequest(packet, this.config.worker, { id, attempt: attempt++ });
|
|
187
|
+
return this.session.send(packet, this.config.worker);
|
|
188
|
+
};
|
|
189
|
+
send();
|
|
190
|
+
retry = setInterval(send, 2000);
|
|
191
|
+
timer = setTimeout(() => resolve({ error: 'outcome_unknown', request_id: id, message: this.options.timeoutMessage || 'Inspect browser_result and current page before repeating an action.' }), timeoutMs);
|
|
192
|
+
});
|
|
193
|
+
if (result.error === 'worker_restarted') this.boot = null;
|
|
194
|
+
return { ...result, request_id: id, route: this.pending.get(id)?.route || null };
|
|
195
|
+
} finally {
|
|
196
|
+
clearInterval(retry); clearTimeout(timer); this.pending.delete(id);
|
|
197
|
+
if (this.session.finishRequest) this.session.finishRequest(id);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async connect() {
|
|
202
|
+
const result = await this.request('hello');
|
|
203
|
+
if (result.error) throw new Error(result.error);
|
|
204
|
+
this.boot = result.boot;
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
close() {
|
|
209
|
+
this.session.off('event', this.listener);
|
|
210
|
+
for (const [id, p] of this.pending) p.resolve({ error: 'outcome_unknown', request_id: id });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { BrowserRpcWorker, BrowserRpcClient, keyBytes, seal, open };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright (c) 2026 Steve Seguin
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const readline = require('node:readline/promises');
|
|
9
|
+
const { validateRelayConfig, HttpRelayTransport } = require('./http-relay-transport');
|
|
10
|
+
const { BrowserRpcClient } = require('./browser-rpc');
|
|
11
|
+
|
|
12
|
+
function relaySettings(file) {
|
|
13
|
+
const value = file ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
14
|
+
return {
|
|
15
|
+
relayUrl: process.env.VDON_MCP_RELAY_URL || value.url,
|
|
16
|
+
relayToken: process.env.VDON_MCP_RELAY_TOKEN || value.token,
|
|
17
|
+
relayKey: process.env.VDON_MCP_RELAY_KEY || value.key
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createSetup(directory, options = {}) {
|
|
22
|
+
const ssapp = options.backend === 'ssapp';
|
|
23
|
+
const transport = options.transport || 'webrtc';
|
|
24
|
+
if (!['webrtc', 'http', 'auto'].includes(transport)) throw new Error('Choose webrtc, http, or auto');
|
|
25
|
+
const connection = { room: `${ssapp ? 'ssapp' : 'browser'}_${crypto.randomBytes(12).toString('hex')}`, transport };
|
|
26
|
+
if (transport !== 'webrtc') {
|
|
27
|
+
const relay = relaySettings(options.relayConfig);
|
|
28
|
+
// Each new browser group gets its own room key while reusing the relay service.
|
|
29
|
+
relay.relayKey = crypto.randomBytes(32).toString('hex');
|
|
30
|
+
validateRelayConfig(relay);
|
|
31
|
+
Object.assign(connection, relay);
|
|
32
|
+
}
|
|
33
|
+
const worker = `${ssapp ? 'ssapp' : 'chrome'}_${crypto.randomBytes(6).toString('hex')}`;
|
|
34
|
+
const clients = {};
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const [client, role] of [['agent1', 'controller'], ['agent2', 'controller'], ['viewer', 'observer']]) {
|
|
37
|
+
const key = crypto.randomBytes(32).toString('hex');
|
|
38
|
+
clients[client] = { role, key };
|
|
39
|
+
files.push([`${client}.json`, { worker, client, key, connection, ...(ssapp ? { backend: 'ssapp' } : {}) }]);
|
|
40
|
+
}
|
|
41
|
+
files.push(['worker.json', { worker, clients, connection, ...(ssapp ? { backend: 'ssapp', ssapp: options.ssapp } : {}), ...(options.media ? { media: { enabled: true } } : {}) }]);
|
|
42
|
+
const script = path.resolve(__dirname, '..', ssapp ? 'vdo-mcp-ssapp.js' : 'vdo-mcp-browser.js');
|
|
43
|
+
files.push(['mcp-client.json', { mcpServers: { [ssapp ? 'ninja-ssapp' : 'ninja-browser']: { command: process.execPath, args: [script, '--config', path.resolve(directory, 'agent1.json')] } } }]);
|
|
44
|
+
for (const [name] of files) if (fs.existsSync(path.join(directory, name))) throw new Error(`${name} already exists; choose a new directory`);
|
|
45
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
46
|
+
for (const [name, value] of files) fs.writeFileSync(path.join(directory, name), JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
47
|
+
return { directory: path.resolve(directory), transport, worker };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function guidedSetup(directory, options = {}) {
|
|
51
|
+
if (!options.transport && process.stdin.isTTY) {
|
|
52
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
53
|
+
try {
|
|
54
|
+
options.transport = (await terminal.question('Connection: webrtc, http, or auto [webrtc]: ')).trim() || 'webrtc';
|
|
55
|
+
if (options.transport !== 'webrtc' && !options.relayConfig && !process.env.VDON_MCP_RELAY_URL) {
|
|
56
|
+
options.relayConfig = (await terminal.question('Private relay JSON file (url, token, key): ')).trim();
|
|
57
|
+
}
|
|
58
|
+
options.media = /^y(es)?$/i.test((await terminal.question('Enable live browser-tab audio/video? [y/N]: ')).trim());
|
|
59
|
+
} finally { terminal.close(); }
|
|
60
|
+
}
|
|
61
|
+
const result = createSetup(directory, options);
|
|
62
|
+
process.stderr.write(`Created private browser configuration in ${result.directory}. Route: ${result.transport}.\n` +
|
|
63
|
+
`Host: vdon-mcp-browser --worker --config "${path.join(result.directory, 'worker.json')}"\n` +
|
|
64
|
+
`Check: vdon-mcp-browser --doctor --config "${path.join(result.directory, 'agent1.json')}"\n` +
|
|
65
|
+
'Give each agent only its own JSON file. mcp-client.json contains the local MCP registration example.\n');
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function doctor(config, makeSession) {
|
|
70
|
+
const checks = [];
|
|
71
|
+
const transport = config.connection.transport || 'webrtc';
|
|
72
|
+
checks.push({ check: 'configuration', ok: true, worker: config.worker, role: config.clients ? 'host' : 'client', transport });
|
|
73
|
+
if (config.clients) {
|
|
74
|
+
const roots = [config.dependencies || process.cwd(), path.resolve(__dirname, '..', '..')];
|
|
75
|
+
for (const [name, version] of [['chrome-devtools-mcp', '1.11.0'], ['puppeteer-core', '24.37.5']]) {
|
|
76
|
+
try {
|
|
77
|
+
const installed = require(require.resolve(`${name}/package.json`, { paths: roots })).version;
|
|
78
|
+
checks.push({ check: name, ok: installed === version, version: installed, action: installed === version ? undefined : `Install ${name}@${version}` });
|
|
79
|
+
} catch (_) { checks.push({ check: name, ok: false, action: `Install ${name}@${version} in the Ninja MCP folder` }); }
|
|
80
|
+
}
|
|
81
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
82
|
+
const supportedNode = major >= 23 || major === 22 && minor >= 12 || major === 20 && minor >= 19;
|
|
83
|
+
checks.push({ check: 'host_node', ok: supportedNode,
|
|
84
|
+
version: process.versions.node, action: supportedNode ? undefined : 'Use Node.js 22.12 or later for the Chrome worker' });
|
|
85
|
+
try {
|
|
86
|
+
const puppeteer = require(require.resolve('puppeteer-core', { paths: roots }));
|
|
87
|
+
const executable = config.executable || puppeteer.executablePath('chrome');
|
|
88
|
+
fs.accessSync(executable, fs.constants.X_OK);
|
|
89
|
+
checks.push({ check: 'chrome_executable', ok: true });
|
|
90
|
+
} catch (_) { checks.push({ check: 'chrome_executable', ok: false, action: 'Install Chrome, or set executable to its absolute path in worker.json' }); }
|
|
91
|
+
}
|
|
92
|
+
if (transport !== 'http') {
|
|
93
|
+
try {
|
|
94
|
+
require('./load-vdo-sdk').loadVDONinjaSDK();
|
|
95
|
+
checks.push({ check: 'webrtc_runtime', ok: true });
|
|
96
|
+
} catch (_) { checks.push({ check: 'webrtc_runtime', ok: transport === 'auto', available: false,
|
|
97
|
+
action: transport === 'auto' ? 'HTTP remains available; install optional WebRTC dependencies to enable the direct route' : 'Install optional WebRTC dependencies, or configure HTTP/auto with a relay' }); }
|
|
98
|
+
}
|
|
99
|
+
if (transport !== 'webrtc') {
|
|
100
|
+
let relay;
|
|
101
|
+
try {
|
|
102
|
+
relay = new HttpRelayTransport({ ...config.connection,
|
|
103
|
+
relayUrl: process.env.VDON_MCP_RELAY_URL || config.connection.relayUrl,
|
|
104
|
+
relayToken: process.env.VDON_MCP_RELAY_TOKEN || config.connection.relayToken,
|
|
105
|
+
relayKey: process.env.VDON_MCP_RELAY_KEY || config.connection.relayKey });
|
|
106
|
+
await relay.request('GET', '');
|
|
107
|
+
checks.push({ check: 'relay_access', ok: true, origin: relay.url.origin });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
checks.push({ check: 'relay_access', ok: false, action: error.status === 401 ? 'Check the relay access token' : 'Check relay configuration, HTTPS reachability and /v1/rooms/ proxy routing' });
|
|
110
|
+
} finally { if (relay) relay.disconnect(); }
|
|
111
|
+
}
|
|
112
|
+
if (!config.clients) {
|
|
113
|
+
let session, rpc;
|
|
114
|
+
try {
|
|
115
|
+
session = makeSession(config, false);
|
|
116
|
+
rpc = new BrowserRpcClient(session, config);
|
|
117
|
+
await session.start();
|
|
118
|
+
const hello = await rpc.request('hello', {}, 12000);
|
|
119
|
+
if (hello.error) throw new Error(hello.error);
|
|
120
|
+
checks.push({ check: 'authenticated_worker', ok: true, role: hello.role, route: hello.route || transport });
|
|
121
|
+
rpc.boot = hello.boot;
|
|
122
|
+
const tabs = await rpc.request('tabs', {}, 12000);
|
|
123
|
+
checks.push({ check: 'browser_tools', ok: !tabs.error && !tabs.result?.isError,
|
|
124
|
+
action: tabs.error || tabs.result?.isError ? 'Restart the browser worker and inspect its host diagnostics' : undefined });
|
|
125
|
+
} catch (_) {
|
|
126
|
+
checks.push({ check: 'authenticated_worker', ok: false,
|
|
127
|
+
action: 'Start the host with its matching worker.json. Check room, worker ID, per-agent key, and at least one shared route. Relay access alone does not prove worker access.' });
|
|
128
|
+
} finally { if (rpc) rpc.close(); if (session) await session.stop(); }
|
|
129
|
+
}
|
|
130
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { relaySettings, createSetup, guidedSetup, doctor };
|