@vdoninja/mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('node:http');
5
+ const { URL } = require('node:url');
6
+ const { VdoMcpServer } = require('./vdo-mcp-server');
7
+ const {
8
+ intFromEnv,
9
+ intFromValue,
10
+ nowISO
11
+ } = require('./lib/bridge-utils');
12
+
13
+ const DEFAULT_PORT = 8787;
14
+ const DEFAULT_HOST = '127.0.0.1';
15
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
16
+
17
+ function jsonResponse(res, statusCode, body, headers) {
18
+ const payload = JSON.stringify(body);
19
+ const baseHeaders = {
20
+ 'content-type': 'application/json; charset=utf-8',
21
+ 'content-length': String(Buffer.byteLength(payload, 'utf8'))
22
+ };
23
+ res.writeHead(statusCode, Object.assign(baseHeaders, headers || {}));
24
+ res.end(payload);
25
+ }
26
+
27
+ function withCorsHeaders(baseHeaders, origin) {
28
+ if (!origin) return baseHeaders || {};
29
+ return Object.assign({}, baseHeaders || {}, {
30
+ 'access-control-allow-origin': origin,
31
+ 'access-control-allow-methods': 'GET,POST,OPTIONS',
32
+ 'access-control-allow-headers': 'authorization,content-type',
33
+ 'access-control-max-age': '600'
34
+ });
35
+ }
36
+
37
+ function parseBearerToken(req) {
38
+ const auth = req.headers.authorization || '';
39
+ const match = /^Bearer\s+(.+)$/i.exec(auth);
40
+ return match ? match[1] : null;
41
+ }
42
+
43
+ function readJsonBody(req, maxBodyBytes) {
44
+ return new Promise((resolve, reject) => {
45
+ let total = 0;
46
+ const chunks = [];
47
+
48
+ req.on('data', (chunk) => {
49
+ total += chunk.length;
50
+ if (total > maxBodyBytes) {
51
+ reject(new Error(`request body exceeds ${maxBodyBytes} bytes`));
52
+ req.destroy();
53
+ return;
54
+ }
55
+ chunks.push(chunk);
56
+ });
57
+
58
+ req.on('end', () => {
59
+ try {
60
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
61
+ if (!raw) {
62
+ resolve({});
63
+ return;
64
+ }
65
+ resolve(JSON.parse(raw));
66
+ } catch (error) {
67
+ reject(error);
68
+ }
69
+ });
70
+
71
+ req.on('error', (error) => reject(error));
72
+ });
73
+ }
74
+
75
+ function createQueueRunner() {
76
+ let chain = Promise.resolve();
77
+ return function run(task) {
78
+ const result = chain.then(task, task);
79
+ chain = result.catch(() => {});
80
+ return result;
81
+ };
82
+ }
83
+
84
+ async function startStreamableHttpServer(options = {}) {
85
+ const host = options.host || DEFAULT_HOST;
86
+ const rawPort = options.port;
87
+ const parsedPort = Number.parseInt(String(rawPort), 10);
88
+ const port = Number.isFinite(parsedPort) && parsedPort >= 0 ? parsedPort : DEFAULT_PORT;
89
+ const endpointPath = options.endpointPath || '/mcp';
90
+ const bearerToken = options.bearerToken || null;
91
+ const allowOrigin = options.allowOrigin || null;
92
+ const maxBodyBytes = intFromValue(options.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
93
+ const logger = options.logger || ((message) => process.stderr.write(`${JSON.stringify(message)}\n`));
94
+ const sdkFactory = options.sdkFactory || null;
95
+
96
+ const server = options.mcpServer || new VdoMcpServer({
97
+ sdkFactory,
98
+ send: () => {},
99
+ logger
100
+ });
101
+ const runInQueue = createQueueRunner();
102
+
103
+ const httpServer = http.createServer(async (req, res) => {
104
+ const reqUrl = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
105
+ const corsHeaders = withCorsHeaders({}, allowOrigin);
106
+
107
+ if (req.method === 'OPTIONS') {
108
+ res.writeHead(204, corsHeaders);
109
+ res.end();
110
+ return;
111
+ }
112
+
113
+ if (reqUrl.pathname === '/health' && req.method === 'GET') {
114
+ jsonResponse(res, 200, {
115
+ ok: true,
116
+ ts: nowISO(),
117
+ mode: 'streamable_http',
118
+ endpoint: endpointPath
119
+ }, corsHeaders);
120
+ return;
121
+ }
122
+
123
+ if (reqUrl.pathname !== endpointPath) {
124
+ jsonResponse(res, 404, {
125
+ ok: false,
126
+ error: 'not_found',
127
+ endpoint: endpointPath
128
+ }, corsHeaders);
129
+ return;
130
+ }
131
+
132
+ if (req.method !== 'POST') {
133
+ jsonResponse(res, 405, {
134
+ ok: false,
135
+ error: 'method_not_allowed'
136
+ }, corsHeaders);
137
+ return;
138
+ }
139
+
140
+ if (bearerToken) {
141
+ const token = parseBearerToken(req);
142
+ if (!token || token !== bearerToken) {
143
+ jsonResponse(res, 401, {
144
+ ok: false,
145
+ error: 'unauthorized'
146
+ }, corsHeaders);
147
+ return;
148
+ }
149
+ }
150
+
151
+ let body;
152
+ try {
153
+ body = await readJsonBody(req, maxBodyBytes);
154
+ } catch (error) {
155
+ jsonResponse(res, 400, {
156
+ ok: false,
157
+ error: 'invalid_json',
158
+ message: error.message
159
+ }, corsHeaders);
160
+ return;
161
+ }
162
+
163
+ let responses;
164
+ try {
165
+ responses = await runInQueue(async () => {
166
+ const collected = [];
167
+ const originalSend = server.send;
168
+ server.send = (message) => collected.push(message);
169
+ try {
170
+ await server.dispatchMessage(body);
171
+ } finally {
172
+ server.send = originalSend;
173
+ }
174
+ return collected;
175
+ });
176
+ } catch (error) {
177
+ jsonResponse(res, 500, {
178
+ ok: false,
179
+ error: 'dispatch_failed',
180
+ message: error.message
181
+ }, corsHeaders);
182
+ return;
183
+ }
184
+
185
+ if (!responses || responses.length === 0) {
186
+ jsonResponse(res, 202, { ok: true, notification: true }, corsHeaders);
187
+ return;
188
+ }
189
+
190
+ const payload = responses.length === 1 ? responses[0] : responses;
191
+ jsonResponse(res, 200, payload, corsHeaders);
192
+ });
193
+
194
+ await new Promise((resolve, reject) => {
195
+ httpServer.on('error', reject);
196
+ httpServer.listen(port, host, resolve);
197
+ });
198
+
199
+ const address = httpServer.address();
200
+ const resolvedPort = address && typeof address === 'object' ? address.port : port;
201
+ const resolvedHost = address && typeof address === 'object' ? address.address : host;
202
+ const baseUrl = `http://${resolvedHost}:${resolvedPort}`;
203
+
204
+ logger({
205
+ type: 'http_server_started',
206
+ ts: nowISO(),
207
+ host: resolvedHost,
208
+ port: resolvedPort,
209
+ endpoint: endpointPath
210
+ });
211
+
212
+ return {
213
+ server: httpServer,
214
+ mcpServer: server,
215
+ baseUrl,
216
+ endpointUrl: `${baseUrl}${endpointPath}`,
217
+ close: async () => {
218
+ await server.shutdownAll();
219
+ await new Promise((resolve, reject) => {
220
+ httpServer.close((error) => {
221
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING' && error.code !== 'EPERM') {
222
+ reject(error);
223
+ return;
224
+ }
225
+ resolve();
226
+ });
227
+ });
228
+ }
229
+ };
230
+ }
231
+
232
+ async function main() {
233
+ let sdkFactory;
234
+ if (process.env.VDON_MCP_FAKE === '1') {
235
+ const { createFakeSDKFactory } = require('./lib/fake-network-sdk');
236
+ sdkFactory = createFakeSDKFactory();
237
+ }
238
+
239
+ const host = process.env.VDON_MCP_HTTP_HOST || DEFAULT_HOST;
240
+ const port = intFromEnv(process.env.VDON_MCP_HTTP_PORT, DEFAULT_PORT);
241
+ const endpointPath = process.env.VDON_MCP_HTTP_PATH || '/mcp';
242
+ const allowOrigin = process.env.VDON_MCP_HTTP_ALLOW_ORIGIN || null;
243
+ const bearerToken = process.env.VDON_MCP_HTTP_BEARER_TOKEN || null;
244
+ const maxBodyBytes = intFromEnv(process.env.VDON_MCP_HTTP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
245
+
246
+ await startStreamableHttpServer({
247
+ host,
248
+ port,
249
+ endpointPath,
250
+ allowOrigin,
251
+ bearerToken,
252
+ maxBodyBytes,
253
+ sdkFactory
254
+ });
255
+ }
256
+
257
+ if (require.main === module) {
258
+ main().catch((error) => {
259
+ process.stderr.write(`${JSON.stringify({
260
+ type: 'fatal',
261
+ ts: nowISO(),
262
+ message: error.message
263
+ })}\n`);
264
+ process.exit(1);
265
+ });
266
+ }
267
+
268
+ module.exports = {
269
+ startStreamableHttpServer
270
+ };
package/server.json ADDED
@@ -0,0 +1,134 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.steveseguin/vdo-ninja-mcp",
4
+ "title": "VDO.Ninja MCP Bridge",
5
+ "description": "WebRTC data-channel MCP bridge for VDO.Ninja with reliable messages, files, and room sync.",
6
+ "version": "0.4.0",
7
+ "websiteUrl": "https://github.com/steveseguin/ninjasdk/tree/main/MCP",
8
+ "repository": {
9
+ "url": "https://github.com/steveseguin/ninjasdk",
10
+ "source": "github",
11
+ "id": "1006762093",
12
+ "subfolder": "MCP"
13
+ },
14
+ "packages": [
15
+ {
16
+ "registryType": "npm",
17
+ "identifier": "@vdoninja/mcp",
18
+ "version": "0.4.0",
19
+ "runtimeHint": "node",
20
+ "runtimeArguments": [
21
+ {
22
+ "type": "positional",
23
+ "value": "scripts/vdo-mcp-server.js"
24
+ }
25
+ ],
26
+ "transport": {
27
+ "type": "stdio"
28
+ },
29
+ "environmentVariables": [
30
+ {
31
+ "name": "VDON_MCP_TOOL_PROFILE",
32
+ "description": "Least-privilege tool set for this server process.",
33
+ "choices": [
34
+ "core",
35
+ "file",
36
+ "state",
37
+ "full"
38
+ ],
39
+ "default": "full",
40
+ "isRequired": false
41
+ },
42
+ {
43
+ "name": "VDON_MCP_MAX_MESSAGE_BYTES",
44
+ "description": "Max MCP JSON-RPC payload size in bytes.",
45
+ "format": "number",
46
+ "default": "1048576",
47
+ "isRequired": false
48
+ },
49
+ {
50
+ "name": "VDON_MCP_JOIN_TOKEN_SECRET",
51
+ "description": "Optional HMAC secret used to verify/generate signed join tokens.",
52
+ "isRequired": false,
53
+ "isSecret": true
54
+ },
55
+ {
56
+ "name": "VDON_MCP_ENFORCE_JOIN_TOKEN",
57
+ "description": "When true, reject peers without valid join tokens.",
58
+ "format": "boolean",
59
+ "default": "false",
60
+ "isRequired": false
61
+ },
62
+ {
63
+ "name": "VDON_MCP_ALLOW_PEER_STREAM_IDS",
64
+ "description": "Optional comma-separated stream_id allowlist for room and file actions.",
65
+ "placeholder": "agent_a,agent_b",
66
+ "isRequired": false
67
+ },
68
+ {
69
+ "name": "VDON_MCP_REQUIRE_SESSION_MAC",
70
+ "description": "When true, require session MAC on file/control envelopes.",
71
+ "format": "boolean",
72
+ "default": "false",
73
+ "isRequired": false
74
+ }
75
+ ]
76
+ }
77
+ ],
78
+ "remotes": [
79
+ {
80
+ "type": "streamable-http",
81
+ "url": "http://127.0.0.1:{http_port}/mcp",
82
+ "variables": {
83
+ "http_port": {
84
+ "description": "Port used by MCP/scripts/vdo-mcp-streamable-http.js.",
85
+ "format": "number",
86
+ "default": "8787",
87
+ "isRequired": false
88
+ }
89
+ }
90
+ }
91
+ ],
92
+ "_meta": {
93
+ "io.modelcontextprotocol.registry/publisher-provided": {
94
+ "com.vdo.ninja": {
95
+ "installChannels": [
96
+ {
97
+ "label": "codex-claude-auto",
98
+ "command": "npm i @vdoninja/mcp @roamhq/wrtc && npx vdon-mcp-install"
99
+ },
100
+ {
101
+ "label": "custom-local-install",
102
+ "command": "npm i @vdoninja/mcp @roamhq/wrtc && npx vdon-mcp-install --name vdo-ninja-mcp"
103
+ },
104
+ {
105
+ "label": "core-install",
106
+ "command": "npm i @vdoninja/mcp @roamhq/wrtc && npx vdon-mcp-install --preset core"
107
+ },
108
+ {
109
+ "label": "secure-core-install",
110
+ "command": "npm i @vdoninja/mcp @roamhq/wrtc && npx vdon-mcp-install --preset secure-core"
111
+ },
112
+ {
113
+ "label": "secure-full-install",
114
+ "command": "npm i @vdoninja/mcp @roamhq/wrtc && npx vdon-mcp-install --preset secure-full"
115
+ },
116
+ {
117
+ "label": "uninstall",
118
+ "command": "npm i @vdoninja/mcp && npx vdon-mcp-install --uninstall"
119
+ }
120
+ ],
121
+ "toolProfiles": {
122
+ "core": "Session + sync messaging tools only.",
123
+ "file": "Core plus reliable file transfer tools.",
124
+ "state": "Core plus shared room-state sync tools.",
125
+ "full": "Core + file + state tool surface."
126
+ },
127
+ "transportNotes": [
128
+ "WebRTC data channels are the transport (not generic TCP tunneling).",
129
+ "TURN improves reachability but cannot bypass every enterprise policy."
130
+ ]
131
+ }
132
+ }
133
+ }
134
+ }