easy-local-mcp 0.3.9
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 +417 -0
- package/chatgpt_plugin.png +0 -0
- package/chatgpt_setting.png +0 -0
- package/dist/agent.js +498 -0
- package/dist/command.js +30 -0
- package/dist/config-watch.js +35 -0
- package/dist/config.js +83 -0
- package/dist/control-endpoint.js +13 -0
- package/dist/control-ui.js +1025 -0
- package/dist/desktop.js +133 -0
- package/dist/index.js +339 -0
- package/dist/lifecycle.js +321 -0
- package/dist/mcp/loader.js +86 -0
- package/dist/process.js +122 -0
- package/dist/relay-config.js +145 -0
- package/dist/relay-protocol.js +34 -0
- package/dist/relay.js +16 -0
- package/dist/security.js +238 -0
- package/dist/server.js +253 -0
- package/dist/skills/loader.js +24 -0
- package/dist/tray.js +74 -0
- package/dist/workspace.js +282 -0
- package/easy-local-mcp.png +0 -0
- package/easy-local-mcp.svg +56 -0
- package/localmcp.example.json +21 -0
- package/package.json +90 -0
- package/scripts/prepare-desktop-bundle.mjs +81 -0
- package/scripts/run-cargo.mjs +35 -0
- package/scripts/run-tauri.mjs +33 -0
- package/scripts/worker-setup.mjs +20 -0
- package/skills/computer-use/SKILL.md +20 -0
- package/skills/computer-use/skill.json +5 -0
- package/skills/local-development/SKILL.md +73 -0
- package/src/relay-protocol.ts +29 -0
- package/worker/index.ts +670 -0
- package/worker/tsconfig.json +1 -0
- package/wrangler.jsonc +10 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createServer as createNetServer } from 'node:net';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { stateDir, logFile, maskMcpUrl, serveControl } from './lifecycle.js';
|
|
9
|
+
import { config } from './config.js';
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
import { Assembly, frames, parseFrame, MAX_BYTES } from './relay-protocol.js';
|
|
12
|
+
import { DEFAULT_PUBLIC_WORKER_URL, validatedWorkerOrigin } from './relay.js';
|
|
13
|
+
import { clearPendingRegistrationToken, configuredRelayUrl, registrationToken, saveRelayPreference } from './relay-config.js';
|
|
14
|
+
import { auditSecurity, getUnlockStatus, lockLocal, resetUnlockOnAgentStart, secureWriteFile, unlockLocal } from './security.js';
|
|
15
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
16
|
+
await resetUnlockOnAgentStart();
|
|
17
|
+
await auditSecurity('agent_start', { pid: process.pid });
|
|
18
|
+
const pidFile = resolve(stateDir, 'agent.pid');
|
|
19
|
+
const workerFile = resolve(stateDir, 'worker.json');
|
|
20
|
+
const connectionFile = resolve(stateDir, 'connection.json');
|
|
21
|
+
let closing = false;
|
|
22
|
+
let ready = false;
|
|
23
|
+
let mcpUrl = null;
|
|
24
|
+
let local;
|
|
25
|
+
let socket;
|
|
26
|
+
let reconnect;
|
|
27
|
+
let reloading;
|
|
28
|
+
let settings;
|
|
29
|
+
let origin;
|
|
30
|
+
async function registerDevice(workerUrl) {
|
|
31
|
+
const target = validatedWorkerOrigin(workerUrl);
|
|
32
|
+
const headers = {
|
|
33
|
+
'Content-Type': 'application/json'
|
|
34
|
+
};
|
|
35
|
+
const token = await registrationToken();
|
|
36
|
+
if (token) {
|
|
37
|
+
headers.Authorization = `Bearer ${token}`;
|
|
38
|
+
}
|
|
39
|
+
if (target.href === validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href) {
|
|
40
|
+
console.error('Security warning: the default public relay is trusted infrastructure and can observe relayed MCP request/response plaintext. Use a self-hosted Worker for sensitive environments.');
|
|
41
|
+
}
|
|
42
|
+
const response = await fetch(new URL('/register', target), {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers,
|
|
45
|
+
body: '{}',
|
|
46
|
+
signal: AbortSignal.timeout(15000)
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Worker registration failed (${response.status}). Check the Relay URL and registration token in the Control Center, or the LOCALMCP_WORKER_URL / LOCALMCP_REGISTRATION_TOKEN environment overrides.`);
|
|
50
|
+
}
|
|
51
|
+
const registered = await response.json();
|
|
52
|
+
if (!registered.workerUrl
|
|
53
|
+
|| !registered.agentToken
|
|
54
|
+
|| !registered.mcpToken) {
|
|
55
|
+
throw new Error('Worker returned an invalid registration response');
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
workerUrl: registered.workerUrl,
|
|
59
|
+
agentToken: registered.agentToken,
|
|
60
|
+
mcpToken: registered.mcpToken,
|
|
61
|
+
deviceId: registered.deviceId
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function loadSettings() {
|
|
65
|
+
const desiredWorkerUrl = await configuredRelayUrl();
|
|
66
|
+
const desiredOrigin = validatedWorkerOrigin(desiredWorkerUrl);
|
|
67
|
+
try {
|
|
68
|
+
settings = JSON.parse(await readFile(workerFile, 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT')
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const registeredOrigin = settings?.workerUrl
|
|
75
|
+
? validatedWorkerOrigin(settings.workerUrl)
|
|
76
|
+
: undefined;
|
|
77
|
+
if (!settings || registeredOrigin?.href !== desiredOrigin.href) {
|
|
78
|
+
settings = await registerDevice(desiredOrigin.href);
|
|
79
|
+
await secureWriteFile(workerFile, JSON.stringify(settings, null, 2));
|
|
80
|
+
await clearPendingRegistrationToken();
|
|
81
|
+
await auditSecurity('worker_registration', {
|
|
82
|
+
deviceId: settings.deviceId,
|
|
83
|
+
publicRelay: desiredOrigin.href === validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href
|
|
84
|
+
});
|
|
85
|
+
console.error(`Registered Easy Local MCP device ${settings.deviceId ?? 'legacy'}.`);
|
|
86
|
+
}
|
|
87
|
+
origin = desiredOrigin;
|
|
88
|
+
}
|
|
89
|
+
function currentMcpUrl() {
|
|
90
|
+
if (!settings || !origin)
|
|
91
|
+
return null;
|
|
92
|
+
const path = settings.deviceId
|
|
93
|
+
? `/mcp/${settings.deviceId}/${settings.mcpToken}`
|
|
94
|
+
: `/mcp/${settings.mcpToken}`;
|
|
95
|
+
return new URL(path, origin).href;
|
|
96
|
+
}
|
|
97
|
+
async function writeConnectionFile() {
|
|
98
|
+
mcpUrl = currentMcpUrl();
|
|
99
|
+
if (!mcpUrl || !settings) {
|
|
100
|
+
throw new Error('Easy Local MCP connection settings are not ready');
|
|
101
|
+
}
|
|
102
|
+
await secureWriteFile(connectionFile, JSON.stringify({
|
|
103
|
+
url: mcpUrl,
|
|
104
|
+
authentication: 'none',
|
|
105
|
+
transport: 'worker-websocket',
|
|
106
|
+
deviceId: settings.deviceId,
|
|
107
|
+
root: process.env.LOCALMCP_ROOT || process.cwd()
|
|
108
|
+
}, null, 2));
|
|
109
|
+
}
|
|
110
|
+
async function reregisterDevice(workerUrl) {
|
|
111
|
+
if (process.env.LOCALMCP_WORKER_URL) {
|
|
112
|
+
throw new Error('Worker origin is controlled by LOCALMCP_WORKER_URL; remove the environment override before changing it in the UI.');
|
|
113
|
+
}
|
|
114
|
+
const requested = validatedWorkerOrigin(workerUrl);
|
|
115
|
+
const next = await registerDevice(requested.href);
|
|
116
|
+
const nextOrigin = validatedWorkerOrigin(next.workerUrl);
|
|
117
|
+
settings = next;
|
|
118
|
+
origin = nextOrigin;
|
|
119
|
+
await saveRelayPreference(nextOrigin.href);
|
|
120
|
+
await secureWriteFile(workerFile, JSON.stringify(settings, null, 2));
|
|
121
|
+
await writeConnectionFile();
|
|
122
|
+
await clearPendingRegistrationToken();
|
|
123
|
+
await auditSecurity('worker_registration', {
|
|
124
|
+
deviceId: settings.deviceId,
|
|
125
|
+
publicRelay: origin.href === validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href
|
|
126
|
+
});
|
|
127
|
+
ready = false;
|
|
128
|
+
socket?.terminate();
|
|
129
|
+
}
|
|
130
|
+
async function rotateCredentials() {
|
|
131
|
+
if (!settings || !origin) {
|
|
132
|
+
throw new Error('Easy Local MCP Worker settings are not ready');
|
|
133
|
+
}
|
|
134
|
+
if (!settings.deviceId) {
|
|
135
|
+
throw new Error('Credential rotation is not available for the legacy single-user Worker model. Rotate Worker secrets and re-register manually.');
|
|
136
|
+
}
|
|
137
|
+
const response = await fetch(new URL(`/rotate/${settings.deviceId}`, origin), {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: {
|
|
140
|
+
Authorization: `Bearer ${settings.agentToken}`
|
|
141
|
+
},
|
|
142
|
+
body: '{}',
|
|
143
|
+
signal: AbortSignal.timeout(15000)
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
throw new Error(`Worker credential rotation failed (${response.status})`);
|
|
147
|
+
}
|
|
148
|
+
const rotated = await response.json();
|
|
149
|
+
if (rotated.deviceId !== settings.deviceId
|
|
150
|
+
|| !rotated.agentToken
|
|
151
|
+
|| !rotated.mcpToken
|
|
152
|
+
|| !rotated.workerUrl) {
|
|
153
|
+
throw new Error('Worker returned an invalid rotation response');
|
|
154
|
+
}
|
|
155
|
+
settings = {
|
|
156
|
+
workerUrl: rotated.workerUrl,
|
|
157
|
+
agentToken: rotated.agentToken,
|
|
158
|
+
mcpToken: rotated.mcpToken,
|
|
159
|
+
deviceId: rotated.deviceId
|
|
160
|
+
};
|
|
161
|
+
await secureWriteFile(workerFile, JSON.stringify(settings, null, 2));
|
|
162
|
+
await writeConnectionFile();
|
|
163
|
+
await auditSecurity('credential_rotation', { deviceId: settings.deviceId });
|
|
164
|
+
socket?.terminate();
|
|
165
|
+
}
|
|
166
|
+
const closeControl = await serveControl(async () => {
|
|
167
|
+
const unlock = await getUnlockStatus();
|
|
168
|
+
return {
|
|
169
|
+
status: 'running',
|
|
170
|
+
pid: process.pid,
|
|
171
|
+
url: mcpUrl,
|
|
172
|
+
config: resolve(process.env.LOCALMCP_CONFIG || resolve(stateDir, 'localmcp.json')),
|
|
173
|
+
log: logFile,
|
|
174
|
+
ready,
|
|
175
|
+
locked: unlock.locked,
|
|
176
|
+
unlockExpiresAt: unlock.expiresAt,
|
|
177
|
+
workerUrl: origin?.href ?? null,
|
|
178
|
+
deviceId: settings?.deviceId ?? null,
|
|
179
|
+
workerManagedByEnv: process.env.LOCALMCP_WORKER_URL !== undefined
|
|
180
|
+
};
|
|
181
|
+
}, {
|
|
182
|
+
stop: () => stop(),
|
|
183
|
+
reload: async () => {
|
|
184
|
+
reloading ??= reloadLocal().finally(() => {
|
|
185
|
+
reloading = undefined;
|
|
186
|
+
});
|
|
187
|
+
await reloading;
|
|
188
|
+
},
|
|
189
|
+
unlock: async (minutes) => {
|
|
190
|
+
await unlockLocal(minutes ?? 30);
|
|
191
|
+
},
|
|
192
|
+
lock: async () => {
|
|
193
|
+
await lockLocal('manual');
|
|
194
|
+
},
|
|
195
|
+
rotate: rotateCredentials,
|
|
196
|
+
reregister: reregisterDevice
|
|
197
|
+
});
|
|
198
|
+
await secureWriteFile(pidFile, String(process.pid));
|
|
199
|
+
process.once('SIGINT', () => stop());
|
|
200
|
+
process.once('SIGTERM', () => stop());
|
|
201
|
+
process.on('SIGHUP', () => {
|
|
202
|
+
if (!reloading) {
|
|
203
|
+
reloading = reloadLocal()
|
|
204
|
+
.catch(error => console.error(error.message))
|
|
205
|
+
.finally(() => {
|
|
206
|
+
reloading = undefined;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
process.on('uncaughtException', error => {
|
|
211
|
+
console.error(error);
|
|
212
|
+
stop(1);
|
|
213
|
+
});
|
|
214
|
+
process.on('unhandledRejection', error => {
|
|
215
|
+
console.error(error);
|
|
216
|
+
stop(1);
|
|
217
|
+
});
|
|
218
|
+
await loadSettings();
|
|
219
|
+
const localToken = randomBytes(32).toString('hex');
|
|
220
|
+
async function findFreePort() {
|
|
221
|
+
if (process.env.LOCALMCP_AGENT_PORT) {
|
|
222
|
+
return Number(process.env.LOCALMCP_AGENT_PORT);
|
|
223
|
+
}
|
|
224
|
+
return await new Promise((resolvePort, reject) => {
|
|
225
|
+
const server = createNetServer();
|
|
226
|
+
server.once('error', reject);
|
|
227
|
+
server.listen(0, '127.0.0.1', () => {
|
|
228
|
+
const address = server.address();
|
|
229
|
+
if (!address || typeof address === 'string') {
|
|
230
|
+
server.close();
|
|
231
|
+
reject(new Error('Unable to allocate local port'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const selected = address.port;
|
|
235
|
+
server.close(error => error ? reject(error) : resolvePort(selected));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const port = await findFreePort();
|
|
240
|
+
function spawnLocal() {
|
|
241
|
+
return spawn(process.execPath, [fileURLToPath(new URL('./index.js', import.meta.url)), 'http'], {
|
|
242
|
+
env: {
|
|
243
|
+
...process.env,
|
|
244
|
+
LOCALMCP_PORT: String(port),
|
|
245
|
+
LOCALMCP_TOKEN: localToken,
|
|
246
|
+
LOCALMCP_INTERNAL: '1'
|
|
247
|
+
},
|
|
248
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
249
|
+
windowsHide: process.platform === 'win32'
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function watchLocal(child) {
|
|
253
|
+
child.on('error', error => {
|
|
254
|
+
console.error(error.message);
|
|
255
|
+
stop(1);
|
|
256
|
+
});
|
|
257
|
+
child.on('exit', code => {
|
|
258
|
+
if (!closing && !reloading && child === local) {
|
|
259
|
+
console.error(`Local server exited (${code})`);
|
|
260
|
+
stop(1);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
local = spawnLocal();
|
|
265
|
+
watchLocal(local);
|
|
266
|
+
function stop(code = 0) {
|
|
267
|
+
if (closing)
|
|
268
|
+
return;
|
|
269
|
+
closing = true;
|
|
270
|
+
ready = false;
|
|
271
|
+
clearTimeout(reconnect);
|
|
272
|
+
socket?.terminate();
|
|
273
|
+
local?.kill('SIGTERM');
|
|
274
|
+
setTimeout(async () => {
|
|
275
|
+
local?.kill('SIGKILL');
|
|
276
|
+
await lockLocal('agent_stop').catch(() => { });
|
|
277
|
+
await auditSecurity('agent_stop', { pid: process.pid, code });
|
|
278
|
+
await unlink(pidFile).catch(() => { });
|
|
279
|
+
await closeControl();
|
|
280
|
+
process.exit(code);
|
|
281
|
+
}, 1500);
|
|
282
|
+
}
|
|
283
|
+
async function reloadLocal() {
|
|
284
|
+
if (closing || !local) {
|
|
285
|
+
throw new Error('Easy Local MCP is not ready');
|
|
286
|
+
}
|
|
287
|
+
await config();
|
|
288
|
+
const previous = local;
|
|
289
|
+
await new Promise(done => {
|
|
290
|
+
const timer = setTimeout(() => previous.kill('SIGKILL'), 5000);
|
|
291
|
+
previous.once('exit', () => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
done();
|
|
294
|
+
});
|
|
295
|
+
previous.kill('SIGTERM');
|
|
296
|
+
});
|
|
297
|
+
if (closing) {
|
|
298
|
+
throw new Error('Easy Local MCP is stopping');
|
|
299
|
+
}
|
|
300
|
+
local = spawnLocal();
|
|
301
|
+
watchLocal(local);
|
|
302
|
+
const deadline = Date.now() + 10000;
|
|
303
|
+
while (Date.now() < deadline && !closing) {
|
|
304
|
+
if (local.exitCode !== null || local.signalCode !== null) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
309
|
+
headers: {
|
|
310
|
+
Authorization: `Bearer ${localToken}`
|
|
311
|
+
},
|
|
312
|
+
signal: AbortSignal.timeout(500)
|
|
313
|
+
});
|
|
314
|
+
if (response.status === 405) {
|
|
315
|
+
await auditSecurity('config_reload');
|
|
316
|
+
console.error('Easy Local MCP configuration reloaded.');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch { }
|
|
321
|
+
await new Promise(resolvePause => setTimeout(resolvePause, 100));
|
|
322
|
+
}
|
|
323
|
+
stop(1);
|
|
324
|
+
throw new Error('Reload failed; see ' + logFile);
|
|
325
|
+
}
|
|
326
|
+
let localReady = false;
|
|
327
|
+
for (let i = 0; i < 100 && !closing; i++) {
|
|
328
|
+
try {
|
|
329
|
+
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
330
|
+
headers: {
|
|
331
|
+
Authorization: `Bearer ${localToken}`
|
|
332
|
+
},
|
|
333
|
+
signal: AbortSignal.timeout(500)
|
|
334
|
+
});
|
|
335
|
+
if (response.status === 405) {
|
|
336
|
+
localReady = true;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch { }
|
|
341
|
+
await new Promise(resolvePause => setTimeout(resolvePause, 100));
|
|
342
|
+
}
|
|
343
|
+
if (!localReady || closing) {
|
|
344
|
+
stop(1);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
let attempt = 0;
|
|
348
|
+
let busy = false;
|
|
349
|
+
await writeConnectionFile();
|
|
350
|
+
function connect() {
|
|
351
|
+
if (closing || !settings || !origin)
|
|
352
|
+
return;
|
|
353
|
+
const wsUrl = new URL(settings.deviceId ? `/agent/${settings.deviceId}` : '/agent', origin);
|
|
354
|
+
wsUrl.protocol = origin.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
355
|
+
const ws = new WebSocket(wsUrl, {
|
|
356
|
+
headers: {
|
|
357
|
+
Authorization: `Bearer ${settings.agentToken}`
|
|
358
|
+
},
|
|
359
|
+
handshakeTimeout: 15000,
|
|
360
|
+
maxPayload: 160000
|
|
361
|
+
});
|
|
362
|
+
socket = ws;
|
|
363
|
+
let assembly;
|
|
364
|
+
let requestId;
|
|
365
|
+
let pong = Date.now();
|
|
366
|
+
const heartbeat = setInterval(() => {
|
|
367
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
368
|
+
return;
|
|
369
|
+
if (Date.now() - pong > 65000) {
|
|
370
|
+
ws.terminate();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
ws.send('ping');
|
|
374
|
+
}, 25000);
|
|
375
|
+
ws.on('open', () => {
|
|
376
|
+
ready = true;
|
|
377
|
+
attempt = 0;
|
|
378
|
+
console.log(`Easy Local MCP is running\n\nMCP URL: ${maskMcpUrl(mcpUrl)}\nUse "easy-local-mcp url" to reveal the full credential-bearing URL.\nConfig: ~/.localmcp/localmcp.json\nSecurity: LOCKED until locally unlocked.\n`);
|
|
379
|
+
});
|
|
380
|
+
const respond = (id, value) => {
|
|
381
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
382
|
+
for (const frame of frames(id, value)) {
|
|
383
|
+
ws.send(frame);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
ws.on('message', async (raw) => {
|
|
388
|
+
const message = raw.toString();
|
|
389
|
+
if (message === 'pong') {
|
|
390
|
+
pong = Date.now();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const frame = parseFrame(message);
|
|
395
|
+
if (!assembly) {
|
|
396
|
+
if (busy) {
|
|
397
|
+
respond(frame.id, {
|
|
398
|
+
status: 429,
|
|
399
|
+
body: JSON.stringify({
|
|
400
|
+
error: 'Local execution still in progress; do not retry automatically.'
|
|
401
|
+
})
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
assembly = new Assembly();
|
|
406
|
+
requestId = frame.id;
|
|
407
|
+
}
|
|
408
|
+
if (requestId !== frame.id) {
|
|
409
|
+
throw new Error('Overlapping requests');
|
|
410
|
+
}
|
|
411
|
+
const complete = assembly.push(frame);
|
|
412
|
+
if (!complete)
|
|
413
|
+
return;
|
|
414
|
+
assembly = undefined;
|
|
415
|
+
requestId = undefined;
|
|
416
|
+
const data = complete.value;
|
|
417
|
+
if (!data
|
|
418
|
+
|| typeof data.body !== 'string'
|
|
419
|
+
|| Buffer.byteLength(data.body) > 2 * 1024 * 1024) {
|
|
420
|
+
throw new Error('Invalid request body');
|
|
421
|
+
}
|
|
422
|
+
JSON.parse(data.body);
|
|
423
|
+
busy = true;
|
|
424
|
+
try {
|
|
425
|
+
const headers = {
|
|
426
|
+
'Content-Type': 'application/json',
|
|
427
|
+
'Accept': 'application/json, text/event-stream',
|
|
428
|
+
Authorization: `Bearer ${localToken}`
|
|
429
|
+
};
|
|
430
|
+
if (data.protocolVersion) {
|
|
431
|
+
headers['MCP-Protocol-Version'] = data.protocolVersion;
|
|
432
|
+
}
|
|
433
|
+
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers,
|
|
436
|
+
body: data.body,
|
|
437
|
+
signal: AbortSignal.timeout(125000)
|
|
438
|
+
});
|
|
439
|
+
const reader = response.body?.getReader();
|
|
440
|
+
let body = '';
|
|
441
|
+
let bytes = 0;
|
|
442
|
+
const decoder = new TextDecoder();
|
|
443
|
+
if (reader) {
|
|
444
|
+
try {
|
|
445
|
+
while (true) {
|
|
446
|
+
const part = await reader.read();
|
|
447
|
+
if (part.done)
|
|
448
|
+
break;
|
|
449
|
+
bytes += part.value.length;
|
|
450
|
+
if (bytes > MAX_BYTES - 1024) {
|
|
451
|
+
await reader.cancel();
|
|
452
|
+
throw new Error('Tool response exceeds relay limit');
|
|
453
|
+
}
|
|
454
|
+
body += decoder.decode(part.value, { stream: true });
|
|
455
|
+
}
|
|
456
|
+
body += decoder.decode();
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
reader.releaseLock();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
respond(frame.id, {
|
|
463
|
+
status: response.status,
|
|
464
|
+
body
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
respond(frame.id, {
|
|
469
|
+
status: 502,
|
|
470
|
+
body: JSON.stringify({
|
|
471
|
+
error: 'Local call failed or timed out. Outcome may be unknown; do not automatically retry.'
|
|
472
|
+
})
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
busy = false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
ws.close(1008, 'Invalid relay request');
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
ws.on('error', error => {
|
|
484
|
+
console.error(`Worker connection error: ${error.message}`);
|
|
485
|
+
});
|
|
486
|
+
ws.on('close', () => {
|
|
487
|
+
ready = false;
|
|
488
|
+
clearInterval(heartbeat);
|
|
489
|
+
if (!closing) {
|
|
490
|
+
const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt++, 5))
|
|
491
|
+
+ Math.random() * 1000;
|
|
492
|
+
console.error(`Worker disconnected; reconnecting in ${Math.ceil(delay / 1000)}s. Requests are not replayed.`);
|
|
493
|
+
reconnect = setTimeout(connect, delay);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
connect();
|
|
498
|
+
}
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function runCommand(command, cwd, timeoutMs) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
// Deliberately do not pass the server's credentials to commands.
|
|
5
|
+
const env = {};
|
|
6
|
+
for (const key of ['PATH', 'HOME', 'USER', 'TMPDIR', 'LANG', 'SHELL', 'SystemRoot'])
|
|
7
|
+
if (process.env[key])
|
|
8
|
+
env[key] = process.env[key];
|
|
9
|
+
const child = spawn(command, { cwd, shell: true, detached: process.platform !== 'win32', env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: process.platform === 'win32' });
|
|
10
|
+
let output = Buffer.alloc(0), timedOut = false, truncated = false;
|
|
11
|
+
const stop = () => { try {
|
|
12
|
+
if (process.platform !== 'win32' && child.pid)
|
|
13
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
14
|
+
else
|
|
15
|
+
child.kill('SIGKILL');
|
|
16
|
+
}
|
|
17
|
+
catch { } };
|
|
18
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
|
|
19
|
+
const collect = (chunk) => {
|
|
20
|
+
const remaining = 256 * 1024 - output.length;
|
|
21
|
+
if (chunk.length > remaining)
|
|
22
|
+
truncated = true;
|
|
23
|
+
output = Buffer.concat([output, chunk.subarray(0, Math.max(0, remaining))]);
|
|
24
|
+
};
|
|
25
|
+
child.stdout.on('data', collect);
|
|
26
|
+
child.stderr.on('data', collect);
|
|
27
|
+
child.on('error', e => { clearTimeout(timer); reject(e); });
|
|
28
|
+
child.on('close', (exitCode, signal) => { clearTimeout(timer); stop(); resolve({ exitCode, signal, output: output.toString('utf8'), timedOut, truncated }); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
// Poll contents rather than a file inode: editors commonly save via rename.
|
|
3
|
+
// A single loop coalesces writes and never overlaps reloads.
|
|
4
|
+
export function watchConfig(path, apply, report, interval = 500) {
|
|
5
|
+
let stopped = false, timer;
|
|
6
|
+
let observed, settled, lastError;
|
|
7
|
+
let running = Promise.resolve();
|
|
8
|
+
async function scan() {
|
|
9
|
+
try {
|
|
10
|
+
const content = await readFile(path, 'utf8');
|
|
11
|
+
lastError = undefined;
|
|
12
|
+
if (stopped)
|
|
13
|
+
return;
|
|
14
|
+
if (content !== observed) {
|
|
15
|
+
observed = content;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (content === settled)
|
|
19
|
+
return;
|
|
20
|
+
// Remember rejected content too, avoiding repeated errors until the next edit.
|
|
21
|
+
settled = content;
|
|
22
|
+
await apply(content);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
if (!stopped && message !== lastError)
|
|
27
|
+
report(error);
|
|
28
|
+
lastError = message;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function tick() { running = scan().finally(() => { if (!stopped)
|
|
32
|
+
timer = setTimeout(tick, interval); }); }
|
|
33
|
+
timer = setTimeout(tick, interval);
|
|
34
|
+
return async () => { stopped = true; clearTimeout(timer); await running; };
|
|
35
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { realpath, stat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const mcpEntrySchema = z.object({ enabled: z.boolean().optional().default(true), command: z.string().min(1), args: z.array(z.string()).optional().default([]), env: z.record(z.string(), z.string()).optional() }).strict();
|
|
6
|
+
const filePermissionsSchema = z.object({ read: z.boolean().optional(), write: z.boolean().optional(), delete: z.boolean().optional() }).strict();
|
|
7
|
+
const featureSchema = z.object({
|
|
8
|
+
files: z.union([z.boolean(), filePermissionsSchema]).optional(),
|
|
9
|
+
shell: z.boolean().optional(),
|
|
10
|
+
processes: z.boolean().optional(),
|
|
11
|
+
externalMcp: z.boolean().optional(),
|
|
12
|
+
}).strict();
|
|
13
|
+
export const localMcpConfigSchema = z.object({
|
|
14
|
+
root: z.string().optional(),
|
|
15
|
+
workspaces: z.record(z.string().min(1), z.string().min(1)).optional(),
|
|
16
|
+
defaultWorkspace: z.string().min(1).optional(),
|
|
17
|
+
features: featureSchema.optional().default({}),
|
|
18
|
+
skills: z.object({ dir: z.string().optional().default('skills'), enabled: z.array(z.string().min(1)).optional() }).strict().optional().default({ dir: 'skills' }),
|
|
19
|
+
mcpServers: z.record(z.string(), mcpEntrySchema).optional().default({}),
|
|
20
|
+
}).strict();
|
|
21
|
+
function parseConfig(raw, path) {
|
|
22
|
+
const result = localMcpConfigSchema.safeParse(raw);
|
|
23
|
+
if (result.success)
|
|
24
|
+
return result.data;
|
|
25
|
+
const details = result.error.issues.map(i => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; ');
|
|
26
|
+
throw new Error(`Invalid Easy Local MCP config ${path}: ${details}`);
|
|
27
|
+
}
|
|
28
|
+
async function readConfig() {
|
|
29
|
+
const explicit = process.env.LOCALMCP_CONFIG, path = explicit ? resolve(explicit) : resolve(homedir(), '.localmcp', 'localmcp.json');
|
|
30
|
+
try {
|
|
31
|
+
return { value: parseConfig(JSON.parse(await readFile(path, 'utf8')), path), base: dirname(path), path };
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (e instanceof SyntaxError)
|
|
35
|
+
throw new Error(`Invalid JSON in Easy Local MCP config ${path}: ${e.message}`);
|
|
36
|
+
if (e.code !== 'ENOENT')
|
|
37
|
+
throw e;
|
|
38
|
+
return { value: localMcpConfigSchema.parse({}), base: homedir() };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function configFilePath() { return resolve(process.env.LOCALMCP_CONFIG || resolve(homedir(), '.localmcp', 'localmcp.json')); }
|
|
42
|
+
export async function config(snapshot) {
|
|
43
|
+
const loaded = snapshot ? { value: parseConfig(JSON.parse(snapshot.content), snapshot.path), base: dirname(snapshot.path), path: snapshot.path } : await readConfig(), c = loaded.value;
|
|
44
|
+
const configured = c.workspaces && Object.keys(c.workspaces).length ? c.workspaces : { default: c.root || '.' };
|
|
45
|
+
const rootOverride = process.env.LOCALMCP_ROOT;
|
|
46
|
+
const expand = (path) => path === '~' || path.startsWith('~/') ? resolve(homedir(), path.slice(2)) : resolve(loaded.base, path);
|
|
47
|
+
const workspaces = {};
|
|
48
|
+
for (const [name, path] of Object.entries(configured)) {
|
|
49
|
+
const selected = rootOverride && name === (c.defaultWorkspace || Object.keys(configured)[0]) ? rootOverride : path;
|
|
50
|
+
const root = await realpath(expand(selected));
|
|
51
|
+
if (!(await stat(root)).isDirectory())
|
|
52
|
+
throw new Error(`Workspace '${name}' must be a directory`);
|
|
53
|
+
workspaces[name] = root;
|
|
54
|
+
}
|
|
55
|
+
const defaultWorkspace = c.defaultWorkspace || Object.keys(workspaces)[0];
|
|
56
|
+
if (!workspaces[defaultWorkspace])
|
|
57
|
+
throw new Error(`Unknown defaultWorkspace '${defaultWorkspace}'`);
|
|
58
|
+
const root = workspaces[defaultWorkspace];
|
|
59
|
+
const port = Number(process.env.LOCALMCP_PORT || 8787);
|
|
60
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
61
|
+
throw new Error('Invalid LOCALMCP_PORT');
|
|
62
|
+
const mcpServers = {};
|
|
63
|
+
for (const [name, m] of Object.entries(c.mcpServers))
|
|
64
|
+
if (m.enabled)
|
|
65
|
+
mcpServers[name] = { command: m.command, args: m.args, env: m.env };
|
|
66
|
+
const files = c.features.files;
|
|
67
|
+
const fileRead = typeof files === 'boolean' ? files : files?.read ?? false;
|
|
68
|
+
const fileWrite = typeof files === 'boolean' ? files : files?.write ?? false;
|
|
69
|
+
const fileDelete = typeof files === 'boolean' ? files : files?.delete ?? false;
|
|
70
|
+
const shell = process.env.LOCALMCP_SHELL !== undefined ? process.env.LOCALMCP_SHELL === '1' : c.features.shell ?? false;
|
|
71
|
+
const processes = (c.features.processes ?? false) && shell;
|
|
72
|
+
// Existing configs with configured MCP servers retain the capability unless they explicitly disable it.
|
|
73
|
+
const externalMcp = c.features.externalMcp ?? Object.keys(mcpServers).length > 0;
|
|
74
|
+
return {
|
|
75
|
+
root, workspaces, defaultWorkspace,
|
|
76
|
+
files: fileRead || fileWrite || fileDelete,
|
|
77
|
+
fileRead, fileWrite, fileDelete,
|
|
78
|
+
shell, processes, externalMcp,
|
|
79
|
+
port, token: process.env.LOCALMCP_TOKEN,
|
|
80
|
+
skillsDir: resolve(loaded.base, c.skills.dir), enabledSkills: c.skills.enabled,
|
|
81
|
+
mcpServers, configFile: loaded.path
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { resolve, win32 } from 'node:path';
|
|
3
|
+
export function controlEndpoint(directory, platform = process.platform) {
|
|
4
|
+
if (platform === 'win32') {
|
|
5
|
+
// Named pipes have a flat namespace. Normalize aliases and keep the name
|
|
6
|
+
// short and deterministic so CLI and Agent agree for each user directory.
|
|
7
|
+
const key = win32.resolve(directory).toLowerCase();
|
|
8
|
+
const id = createHash('sha256').update(key).digest('hex').slice(0, 32);
|
|
9
|
+
return { address: `\\\\.\\pipe\\localmcp-${id}` };
|
|
10
|
+
}
|
|
11
|
+
const socketFile = resolve(directory, 'agent.sock');
|
|
12
|
+
return { address: socketFile, socketFile };
|
|
13
|
+
}
|