@vo1d7bin/remote-mcp-agent 0.1.0 → 0.2.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/README.md +42 -42
- package/bin/vo1d-agent.mjs +195 -112
- package/package.json +32 -32
package/README.md
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
# vo1d Remote MCP device agent
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Requires Node.js 22.14 or newer.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```text
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
For a
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
1
|
+
# vo1d Remote MCP device agent
|
|
2
|
+
|
|
3
|
+
Connect your own Windows, Linux, or macOS computer to your account on **https://mcp.thelinuxserver.cloud** using one command. This agent is independent of Desktop Commander and its hosted relay.
|
|
4
|
+
|
|
5
|
+
Requires Node.js 22.14 or newer.
|
|
6
|
+
|
|
7
|
+
## One-command connection
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest remote
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**First run:** The agent prints a verification code and opens your default browser. Sign in with Google if needed, compare the code shown in the terminal and browser, then select **Approve device**. The agent automatically receives device credentials, stores them securely in your user profile, and connects to our relay.
|
|
14
|
+
|
|
15
|
+
**Later runs:** Execute the **same command**. Previously saved device credentials are reused, so no browser approval is required. The agent reconnects automatically after temporary connection interruptions while it remains running.
|
|
16
|
+
|
|
17
|
+
The terminal must remain open for the agent to remain online. We may add an installer/service or desktop tray application in a future version.
|
|
18
|
+
|
|
19
|
+
By default, the agent permits **system information only**. To additionally expose names of files and directories within a specific, safe folder, use:
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest remote --allow-root "$HOME\mcp-share"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The specified directory must already exist. Do not grant access to your full home directory, system drive, or secrets. Only the folder and its descendants are visible. No file contents, writes, or terminal execution are provided in this version.
|
|
26
|
+
|
|
27
|
+
## Advanced options
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
vo1d-agent remote [--allow-root PATH] [--server https://mcp.thelinuxserver.cloud]
|
|
31
|
+
vo1d-agent enroll [--server https://mcp.thelinuxserver.cloud]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For a headless machine, run with `--no-browser`; the printed link can be opened from a browser on any other device. Use `VO1D_ALLOWED_ROOTS` for multiple approved folders, separated by your OS path delimiter (semicolon on Windows, colon on Linux).
|
|
35
|
+
|
|
36
|
+
For a separate agent identity on the same computer, set `VO1D_CONFIG_DIR` to an independent directory **before** running `remote`. Device credentials are stored at `~/.vo1d-remote-mcp/device.json` by default. Keep this private; never commit it to source control.
|
|
37
|
+
|
|
38
|
+
The legacy `enroll` command is available for manual one-time pairing codes. Open **Devices → Pair a device → Manual pairing** if the browser-based flow cannot be used.
|
|
39
|
+
|
|
40
|
+
Device access can be revoked at any time in your vo1d dashboard. If revoked, the stored credentials cannot reconnect and the machine must be authorized again.
|
|
41
|
+
|
|
42
|
+
Source: https://github.com/THE-L1NUX-USER/vo1d-remote-mcp
|
package/bin/vo1d-agent.mjs
CHANGED
|
@@ -1,112 +1,195 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import readline from 'node:readline/promises';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
|
|
10
|
+
const folder = process.env.VO1D_CONFIG_DIR
|
|
11
|
+
? path.resolve(process.env.VO1D_CONFIG_DIR)
|
|
12
|
+
: path.join(os.homedir(), '.vo1d-remote-mcp');
|
|
13
|
+
const configFile = path.join(folder, 'device.json');
|
|
14
|
+
const arg = name => {
|
|
15
|
+
const i = process.argv.indexOf(name);
|
|
16
|
+
return i < 0 ? undefined : process.argv[i + 1];
|
|
17
|
+
};
|
|
18
|
+
function normalizedServer(raw) {
|
|
19
|
+
const url = new URL(raw);
|
|
20
|
+
if (url.protocol !== 'https:' &&
|
|
21
|
+
!(url.protocol === 'http:' && ['127.0.0.1','localhost'].includes(url.hostname))) {
|
|
22
|
+
throw new Error('HTTPS is required outside localhost');
|
|
23
|
+
}
|
|
24
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
25
|
+
throw new Error('Expected a server origin, not a URL with a path or credentials');
|
|
26
|
+
}
|
|
27
|
+
return url.origin;
|
|
28
|
+
}
|
|
29
|
+
function defaultServer() {
|
|
30
|
+
return normalizedServer(arg('--server') || process.env.VO1D_SERVER || 'https://mcp.thelinuxserver.cloud');
|
|
31
|
+
}
|
|
32
|
+
async function saveDevice(cfg) {
|
|
33
|
+
await fs.mkdir(folder, {recursive:true, mode:0o700});
|
|
34
|
+
try {
|
|
35
|
+
await fs.writeFile(configFile, JSON.stringify(cfg,null,2), {mode:0o600,flag:'wx'});
|
|
36
|
+
} catch(error) {
|
|
37
|
+
if(error.code==='EEXIST')throw new Error('Device already enrolled in this profile; use remote to reconnect');
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function legacyEnroll() {
|
|
42
|
+
const server=defaultServer();
|
|
43
|
+
const rl=readline.createInterface({input:process.stdin,output:process.stdout});
|
|
44
|
+
let code;
|
|
45
|
+
try {code=(await rl.question('Paste the one-time pairing code: ')).trim();}
|
|
46
|
+
finally {rl.close();}
|
|
47
|
+
const reply=await fetch(server+'/api/agent/enroll',{
|
|
48
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
49
|
+
body:JSON.stringify({code,name:arg('--name')||os.hostname(),platform:process.platform})
|
|
50
|
+
});
|
|
51
|
+
if(!reply.ok)throw new Error('Device pairing failed (HTTP '+reply.status+')');
|
|
52
|
+
const {id,token}=await reply.json();
|
|
53
|
+
await saveDevice({server,id,token});
|
|
54
|
+
console.log('Paired successfully. Device ID:',id);
|
|
55
|
+
console.log('To connect, run: vo1d-agent remote');
|
|
56
|
+
}
|
|
57
|
+
function openBrowser(address) {
|
|
58
|
+
if (process.argv.includes('--no-browser') || process.env.VO1D_NO_BROWSER==='true') return false;
|
|
59
|
+
const program=process.platform==='win32'?'explorer.exe':process.platform==='darwin'?'open':'xdg-open';
|
|
60
|
+
try {
|
|
61
|
+
const child=spawn(program,[address],{stdio:'ignore',detached:true,windowsHide:true});
|
|
62
|
+
child.on('error',()=>{}); // Link is always printed for environments without a browser.
|
|
63
|
+
child.unref();
|
|
64
|
+
return true;
|
|
65
|
+
} catch {return false;}
|
|
66
|
+
}
|
|
67
|
+
async function browserEnroll(server) {
|
|
68
|
+
const response=await fetch(server+'/api/agent/authorization/start',{
|
|
69
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
70
|
+
body:JSON.stringify({name:arg('--name')||os.hostname(),platform:process.platform})
|
|
71
|
+
});
|
|
72
|
+
if(!response.ok)throw new Error('Could not start browser authorization (HTTP '+response.status+')');
|
|
73
|
+
const data=await response.json();
|
|
74
|
+
if(typeof data.requestId!=='string' || typeof data.pollToken!=='string')throw new Error('Unexpected authorization response');
|
|
75
|
+
const link=new URL(data.verificationUrl);
|
|
76
|
+
if(link.origin!==server || link.pathname!=='/device/authorize')throw new Error('Unexpected authorization destination');
|
|
77
|
+
console.log('\nFirst-time connection: approve this device in your web browser.');
|
|
78
|
+
console.log('Verification code:',data.userCode);
|
|
79
|
+
console.log('Open:',link.href);
|
|
80
|
+
openBrowser(link.href);
|
|
81
|
+
console.log('Waiting for approval (expires in 10 minutes). Press Ctrl+C to cancel.\n');
|
|
82
|
+
const deadline=Math.min(Number(data.expiresAt),Date.now()+10*60_000);
|
|
83
|
+
let failure=0;
|
|
84
|
+
while(Date.now()<deadline) {
|
|
85
|
+
await sleep(Math.max(2000,Number(data.intervalSeconds||3)*1000));
|
|
86
|
+
let poll;
|
|
87
|
+
try {
|
|
88
|
+
poll=await fetch(server+'/api/agent/authorization/poll',{
|
|
89
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
90
|
+
body:JSON.stringify({requestId:data.requestId,pollToken:data.pollToken}),
|
|
91
|
+
signal:AbortSignal.timeout(12000)
|
|
92
|
+
});
|
|
93
|
+
} catch(error) {
|
|
94
|
+
failure++;
|
|
95
|
+
if(failure>=5)throw new Error('Unable to reach the authorization server: '+error.message);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
failure=0;
|
|
99
|
+
const info=await poll.json().catch(()=>({}));
|
|
100
|
+
if(info.status==='pending')continue;
|
|
101
|
+
if(info.status==='approved' && typeof info.id==='string' && typeof info.token==='string') {
|
|
102
|
+
const cfg={server,id:info.id,token:info.token};
|
|
103
|
+
await saveDevice(cfg);
|
|
104
|
+
console.log('Device approved and credentials stored securely for future runs.');
|
|
105
|
+
return cfg;
|
|
106
|
+
}
|
|
107
|
+
if(info.status==='denied')throw new Error('This device authorization was denied');
|
|
108
|
+
if(info.status==='expired' || poll.status===410)throw new Error('Authorization expired; rerun the command');
|
|
109
|
+
throw new Error('Authorization failed (HTTP '+poll.status+'). Rerun the command.');
|
|
110
|
+
}
|
|
111
|
+
throw new Error('Device authorization expired; rerun the command');
|
|
112
|
+
}
|
|
113
|
+
async function loadOrEnroll() {
|
|
114
|
+
try {
|
|
115
|
+
const cfg=JSON.parse(await fs.readFile(configFile,'utf8'));
|
|
116
|
+
if(typeof cfg.id!=='string' || typeof cfg.token!=='string' || cfg.token.length<30) {
|
|
117
|
+
throw new Error('Stored device credentials appear corrupted');
|
|
118
|
+
}
|
|
119
|
+
return cfg;
|
|
120
|
+
} catch(error) {
|
|
121
|
+
if(error.code!=='ENOENT')throw error;
|
|
122
|
+
return browserEnroll(defaultServer());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function allowedRoots() {
|
|
126
|
+
const provided=process.env.VO1D_ALLOWED_ROOTS || arg('--allow-root') || '';
|
|
127
|
+
const paths=provided.split(path.delimiter).map(x=>x.trim()).filter(Boolean);
|
|
128
|
+
const roots=[];
|
|
129
|
+
for(const p of paths)roots.push(await fs.realpath(path.resolve(p)));
|
|
130
|
+
return roots;
|
|
131
|
+
}
|
|
132
|
+
function includesRoot(p,root) {
|
|
133
|
+
const value=process.platform==='win32'?p.toLowerCase():p;
|
|
134
|
+
const base=process.platform==='win32'?root.toLowerCase():root;
|
|
135
|
+
return value===base || value.startsWith(base.endsWith(path.sep)?base:base+path.sep);
|
|
136
|
+
}
|
|
137
|
+
async function directoryListing(requestPath,roots) {
|
|
138
|
+
if(!roots.length)throw new Error('No allowed roots configured; set VO1D_ALLOWED_ROOTS');
|
|
139
|
+
if(typeof requestPath!=='string' || requestPath.length>2000)throw new Error('Invalid directory path');
|
|
140
|
+
const resolved=await fs.realpath(path.resolve(requestPath));
|
|
141
|
+
if(!roots.some(root=>includesRoot(resolved,root)))throw new Error('Path outside allowed directories');
|
|
142
|
+
const entries=await fs.readdir(resolved,{withFileTypes:true});
|
|
143
|
+
return {path:resolved,truncated:entries.length>100,items:entries.slice(0,100).map(entry=>({
|
|
144
|
+
name:entry.name,type:entry.isDirectory()?'directory':entry.isFile()?'file':'other'
|
|
145
|
+
}))};
|
|
146
|
+
}
|
|
147
|
+
async function remote() {
|
|
148
|
+
const roots=await allowedRoots();
|
|
149
|
+
const cfg=await loadOrEnroll();
|
|
150
|
+
const server=normalizedServer(cfg.server);
|
|
151
|
+
console.log('Device:',cfg.id,'| Allowed filesystem roots:',roots.length?roots.join(' ; '):'(none)');
|
|
152
|
+
const endpoint=server.replace(/^http/,'ws')+'/agent';
|
|
153
|
+
let stopped=false,retry=1000;
|
|
154
|
+
process.on('SIGINT',()=>{stopped=true;process.exit(0);});
|
|
155
|
+
async function connect() {
|
|
156
|
+
if(stopped)return;
|
|
157
|
+
const ws=new WebSocket(endpoint,{headers:{Authorization:'Bearer '+cfg.token},handshakeTimeout:12000});
|
|
158
|
+
ws.on('open',()=>{retry=1000;console.log('Connected to',server);});
|
|
159
|
+
ws.on('message',async raw=>{
|
|
160
|
+
let task;
|
|
161
|
+
try{task=JSON.parse(raw.toString());}catch{return;}
|
|
162
|
+
if(typeof task.id!=='string')return;
|
|
163
|
+
try {
|
|
164
|
+
let result;
|
|
165
|
+
if(task.action==='system_info'){
|
|
166
|
+
result={hostname:os.hostname(),platform:process.platform,arch:process.arch,
|
|
167
|
+
release:os.release(),cpus:os.cpus().length,memoryBytes:os.totalmem(),allowedRoots:roots};
|
|
168
|
+
}else if(task.action==='list_directory'){
|
|
169
|
+
result=await directoryListing(task.args?.path,roots);
|
|
170
|
+
}else throw new Error('Unsupported action');
|
|
171
|
+
if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:task.id,ok:true,result}));
|
|
172
|
+
}catch(error){
|
|
173
|
+
if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({
|
|
174
|
+
id:task.id,ok:false,error:String(error.message).slice(0,150)
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
ws.on('error',error=>console.error('Connection:',error.message));
|
|
179
|
+
ws.on('close',code=>{
|
|
180
|
+
if(code===4000){console.log('Another session for this device has taken over. Exiting this agent.');stopped=true;return;}
|
|
181
|
+
if(code===4001){console.error('Device revoked. Stop this process and request a new authorization.');stopped=true;return;}
|
|
182
|
+
if(stopped)return;
|
|
183
|
+
const delay=retry;retry=Math.min(retry*2,30000);
|
|
184
|
+
console.log('Disconnected. Retrying in',delay/1000,'seconds.');
|
|
185
|
+
setTimeout(connect,delay);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
await connect();
|
|
189
|
+
}
|
|
190
|
+
const command=process.argv[2];
|
|
191
|
+
if(command==='enroll')legacyEnroll().catch(error=>{console.error(error.message);process.exitCode=1;});
|
|
192
|
+
else if(command==='remote')remote().catch(error=>{console.error(error.message);process.exitCode=1;});
|
|
193
|
+
else console.log('Usage: vo1d-agent remote [--allow-root PATH] [--server https://mcp.thelinuxserver.cloud]\n' +
|
|
194
|
+
' vo1d-agent enroll (manual pairing for headless environments)\n' +
|
|
195
|
+
'The remote command opens a browser on first run and reconnects on subsequent runs.');
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@vo1d7bin/remote-mcp-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Independent vo1d Remote MCP device agent for Windows, Linux and macOS",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/THE-L1NUX-USER/vo1d-remote-mcp.git",
|
|
10
|
-
"directory": "packages/agent"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://mcp.thelinuxserver.cloud",
|
|
13
|
-
"bin": {
|
|
14
|
-
"vo1d-agent": "bin/vo1d-agent.mjs"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"bin/",
|
|
18
|
-
"README.md"
|
|
19
|
-
],
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=22.14.0"
|
|
22
|
-
},
|
|
23
|
-
"dependencies": {
|
|
24
|
-
"ws": "8.18.3"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"prepack": "node ../../scripts/sync-agent.mjs"
|
|
28
|
-
},
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@vo1d7bin/remote-mcp-agent",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Independent vo1d Remote MCP device agent for Windows, Linux and macOS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/THE-L1NUX-USER/vo1d-remote-mcp.git",
|
|
10
|
+
"directory": "packages/agent"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://mcp.thelinuxserver.cloud",
|
|
13
|
+
"bin": {
|
|
14
|
+
"vo1d-agent": "bin/vo1d-agent.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.14.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ws": "8.18.3"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepack": "node ../../scripts/sync-agent.mjs"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|