@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.
- package/SKILL.md +90 -0
- package/assets/cloudflare/worker-proxy.js +89 -0
- package/assets/cloudflare/wrangler.toml.example +10 -0
- package/package.json +66 -0
- package/references/advanced-diagnostics.md +45 -0
- package/references/mcp-tool-contract.md +184 -0
- package/references/quickstart-and-compat.md +198 -0
- package/references/vdo-ninja-p2p-data-notes.md +156 -0
- package/scripts/demo-file-workflow.js +168 -0
- package/scripts/demo-message-workflow.js +167 -0
- package/scripts/install-mcp.js +302 -0
- package/scripts/lib/bridge-session.js +2331 -0
- package/scripts/lib/bridge-utils.js +185 -0
- package/scripts/lib/fake-network-sdk.js +293 -0
- package/scripts/lib/load-vdo-sdk.js +36 -0
- package/scripts/lib/mcp-stdio-parser.js +137 -0
- package/scripts/lib/tool-profiles.js +122 -0
- package/scripts/vdo-data-bridge.js +157 -0
- package/scripts/vdo-mcp-server.js +1425 -0
- package/scripts/vdo-mcp-streamable-http.js +270 -0
- package/server.json +134 -0
|
@@ -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
|
+
}
|