@vdoninja/mcp 0.4.1 → 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 -661
- package/README.md +59 -272
- 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 +51 -9
- package/references/advanced-diagnostics.md +2 -0
- package/references/browser-agent.md +53 -0
- package/references/browser-worker.md +142 -0
- package/references/client-config-examples.md +2 -0
- package/references/direction.md +88 -0
- package/references/http-relay.md +107 -0
- package/references/mcp-tool-contract.md +11 -1
- package/references/quickstart-and-compat.md +4 -1
- package/references/ssapp-integration.md +371 -0
- package/references/vdo-ninja-p2p-data-notes.md +2 -0
- package/scripts/demo-file-workflow.js +3 -0
- package/scripts/demo-message-workflow.js +3 -0
- package/scripts/install-mcp.js +5 -2
- 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 +50 -7
- package/scripts/vdo-mcp-ssapp.js +208 -0
- package/scripts/vdo-mcp-streamable-http.js +23 -4
- package/server.json +22 -4
- package/LICENSE-SDK-EXCEPTION +0 -54
|
@@ -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.
|
|
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
|
|
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
|
|
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: {
|
|
@@ -606,7 +614,8 @@ class VdoMcpServer {
|
|
|
606
614
|
'join_token', 'join_token_secret', 'join_token_ttl_ms', 'enforce_join_token', 'allow_peer_stream_ids',
|
|
607
615
|
'file_chunk_bytes', 'file_max_bytes', 'file_ack_timeout_ms', 'file_max_retries', 'require_session_mac',
|
|
608
616
|
'file_spool_dir', 'file_spool_threshold_bytes', 'keep_spool_files',
|
|
609
|
-
'state_max_keys', 'state_max_snapshot_entries'
|
|
617
|
+
'state_max_keys', 'state_max_snapshot_entries',
|
|
618
|
+
'transport', 'relay_url', 'relay_token', 'relay_key'
|
|
610
619
|
],
|
|
611
620
|
'vdo_connect'
|
|
612
621
|
);
|
|
@@ -616,6 +625,22 @@ class VdoMcpServer {
|
|
|
616
625
|
throw new Error('room is required');
|
|
617
626
|
}
|
|
618
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
|
+
|
|
619
644
|
if (args.host !== undefined && typeof args.host !== 'string') {
|
|
620
645
|
throw new Error('host must be a string');
|
|
621
646
|
}
|
|
@@ -665,6 +690,8 @@ class VdoMcpServer {
|
|
|
665
690
|
: this.defaultAllowPeerStreamIDs;
|
|
666
691
|
|
|
667
692
|
return {
|
|
693
|
+
transport,
|
|
694
|
+
...(transport === 'http' ? relayConfig : {}),
|
|
668
695
|
host: typeof args.host === 'string' && args.host.trim() ? args.host : 'wss://wss.vdo.ninja',
|
|
669
696
|
room,
|
|
670
697
|
streamID,
|
|
@@ -781,7 +808,8 @@ class VdoMcpServer {
|
|
|
781
808
|
session_id: sessionId,
|
|
782
809
|
...session.getStatus(),
|
|
783
810
|
effective_config: {
|
|
784
|
-
|
|
811
|
+
transport: config.transport,
|
|
812
|
+
host: config.transport === 'http' ? config.relayUrl : config.host,
|
|
785
813
|
room: config.room,
|
|
786
814
|
stream_id: config.streamID,
|
|
787
815
|
target_stream_id: config.targetStreamID,
|
|
@@ -823,7 +851,11 @@ class VdoMcpServer {
|
|
|
823
851
|
return {
|
|
824
852
|
ok,
|
|
825
853
|
session_id: args.session_id,
|
|
826
|
-
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
|
+
} : {})
|
|
827
859
|
};
|
|
828
860
|
}
|
|
829
861
|
|
|
@@ -901,10 +933,21 @@ class VdoMcpServer {
|
|
|
901
933
|
features: {
|
|
902
934
|
session_messaging: true,
|
|
903
935
|
file_transfer: hasFileTools,
|
|
904
|
-
shared_state_sync: hasStateTools
|
|
936
|
+
shared_state_sync: hasStateTools,
|
|
937
|
+
live_audio_video: false
|
|
905
938
|
},
|
|
906
939
|
transport_truth: {
|
|
907
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
|
+
},
|
|
908
951
|
generic_tcp_tunnel: false,
|
|
909
952
|
turn_relays_help_reachability: true,
|
|
910
953
|
guaranteed_firewall_bypass: false,
|
|
@@ -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:
|
|
218
|
-
|
|
219
|
-
|
|
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
|
}
|
package/server.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.steveseguin/vdo-ninja-mcp",
|
|
4
4
|
"title": "VDO.Ninja MCP Bridge",
|
|
5
|
-
"description": "
|
|
6
|
-
"version": "0.4.
|
|
5
|
+
"description": "Private agent rooms with messages, files, and shared state over WebRTC or a self-hosted HTTP relay.",
|
|
6
|
+
"version": "0.4.2",
|
|
7
7
|
"websiteUrl": "https://github.com/steveseguin/ninjamcp",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/steveseguin/ninjamcp",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "@vdoninja/mcp",
|
|
16
|
-
"version": "0.4.
|
|
16
|
+
"version": "0.4.2",
|
|
17
17
|
"runtimeHint": "node",
|
|
18
18
|
"runtimeArguments": [
|
|
19
19
|
{
|
|
@@ -25,6 +25,23 @@
|
|
|
25
25
|
"type": "stdio"
|
|
26
26
|
},
|
|
27
27
|
"environmentVariables": [
|
|
28
|
+
{
|
|
29
|
+
"name": "VDON_MCP_RELAY_URL",
|
|
30
|
+
"description": "HTTPS origin of an optional peer relay, used with transport: http.",
|
|
31
|
+
"isRequired": false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "VDON_MCP_RELAY_TOKEN",
|
|
35
|
+
"description": "Access token for the optional peer relay.",
|
|
36
|
+
"isRequired": false,
|
|
37
|
+
"isSecret": true
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "VDON_MCP_RELAY_KEY",
|
|
41
|
+
"description": "Shared room encryption key: 32 random bytes encoded as 64 hex characters.",
|
|
42
|
+
"isRequired": false,
|
|
43
|
+
"isSecret": true
|
|
44
|
+
},
|
|
28
45
|
{
|
|
29
46
|
"name": "VDON_MCP_TOOL_PROFILE",
|
|
30
47
|
"description": "Least-privilege tool set for this server process.",
|
|
@@ -123,7 +140,8 @@
|
|
|
123
140
|
"full": "Core + file + state tool surface."
|
|
124
141
|
},
|
|
125
142
|
"transportNotes": [
|
|
126
|
-
"WebRTC data channels are the transport
|
|
143
|
+
"WebRTC data channels are the default peer transport; HTTP relay mode is explicit and works without WebRTC.",
|
|
144
|
+
"The optional peer relay uses shared room encryption keys and bounded in-memory replay.",
|
|
127
145
|
"TURN improves reachability but cannot bypass every enterprise policy."
|
|
128
146
|
]
|
|
129
147
|
}
|