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.
@@ -0,0 +1,133 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { access } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ function windowsPaths(env) {
5
+ const values = [];
6
+ const add = (base, ...parts) => {
7
+ if (base)
8
+ values.push(join(base, ...parts));
9
+ };
10
+ add(env['ProgramFiles(x86)'], 'Microsoft', 'Edge', 'Application', 'msedge.exe');
11
+ add(env.ProgramFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe');
12
+ add(env.LOCALAPPDATA, 'Microsoft', 'Edge', 'Application', 'msedge.exe');
13
+ add(env['ProgramFiles(x86)'], 'Google', 'Chrome', 'Application', 'chrome.exe');
14
+ add(env.ProgramFiles, 'Google', 'Chrome', 'Application', 'chrome.exe');
15
+ add(env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe');
16
+ return values;
17
+ }
18
+ export function desktopAppCandidates(url, platform = process.platform, env = process.env) {
19
+ const appArgs = [`--app=${url}`, '--new-window'];
20
+ if (env.LOCALMCP_DESKTOP_BROWSER) {
21
+ return [{
22
+ command: env.LOCALMCP_DESKTOP_BROWSER,
23
+ args: appArgs,
24
+ checkPath: false
25
+ }];
26
+ }
27
+ if (platform === 'win32') {
28
+ return [
29
+ ...windowsPaths(env).map(command => ({ command, args: appArgs, checkPath: true })),
30
+ { command: 'msedge.exe', args: appArgs, checkPath: false },
31
+ { command: 'chrome.exe', args: appArgs, checkPath: false }
32
+ ];
33
+ }
34
+ if (platform === 'darwin') {
35
+ return [
36
+ {
37
+ command: 'open',
38
+ args: ['-na', 'Microsoft Edge', '--args', ...appArgs],
39
+ checkPath: false,
40
+ waitForExit: true
41
+ },
42
+ {
43
+ command: 'open',
44
+ args: ['-na', 'Google Chrome', '--args', ...appArgs],
45
+ checkPath: false,
46
+ waitForExit: true
47
+ }
48
+ ];
49
+ }
50
+ return [
51
+ { command: 'microsoft-edge', args: appArgs, checkPath: false },
52
+ { command: 'google-chrome', args: appArgs, checkPath: false },
53
+ { command: 'chromium', args: appArgs, checkPath: false },
54
+ { command: 'chromium-browser', args: appArgs, checkPath: false }
55
+ ];
56
+ }
57
+ export function defaultBrowserCandidate(url, platform = process.platform) {
58
+ if (platform === 'win32') {
59
+ return {
60
+ command: 'rundll32.exe',
61
+ args: ['url.dll,FileProtocolHandler', url],
62
+ waitForExit: false
63
+ };
64
+ }
65
+ if (platform === 'darwin') {
66
+ return {
67
+ command: 'open',
68
+ args: [url],
69
+ waitForExit: true
70
+ };
71
+ }
72
+ return {
73
+ command: 'xdg-open',
74
+ args: [url],
75
+ waitForExit: true
76
+ };
77
+ }
78
+ async function exists(path) {
79
+ try {
80
+ await access(path);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ async function launch(command, args, waitForExit = false) {
88
+ return await new Promise(resolveLaunch => {
89
+ const child = spawn(command, args, {
90
+ detached: !waitForExit,
91
+ stdio: 'ignore',
92
+ windowsHide: true
93
+ });
94
+ let settled = false;
95
+ const finish = (value) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ resolveLaunch(value);
100
+ };
101
+ child.once('error', () => finish(false));
102
+ child.once('spawn', () => {
103
+ if (!waitForExit) {
104
+ child.unref();
105
+ finish(true);
106
+ }
107
+ });
108
+ if (waitForExit) {
109
+ child.once('exit', code => finish(code === 0));
110
+ }
111
+ });
112
+ }
113
+ export async function openDesktopControl(url, platform = process.platform, env = process.env) {
114
+ for (const candidate of desktopAppCandidates(url, platform, env)) {
115
+ if (candidate.checkPath && !(await exists(candidate.command)))
116
+ continue;
117
+ if (await launch(candidate.command, candidate.args, candidate.waitForExit === true)) {
118
+ return {
119
+ mode: 'app',
120
+ command: candidate.command
121
+ };
122
+ }
123
+ }
124
+ const fallback = defaultBrowserCandidate(url, platform);
125
+ if (await launch(fallback.command, fallback.args, fallback.waitForExit)) {
126
+ return {
127
+ mode: 'browser',
128
+ command: fallback.command
129
+ };
130
+ }
131
+ throw new Error('Unable to open the Easy Local MCP desktop control window. '
132
+ + 'Set LOCALMCP_DESKTOP_BROWSER to an Edge/Chrome executable or open the printed local UI URL manually.');
133
+ }
package/dist/index.js ADDED
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import express from 'express';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { config, configFilePath } from './config.js';
8
+ import { watchConfig } from './config-watch.js';
9
+ import { createServer } from './server.js';
10
+ import { McpLoader } from './mcp/loader.js';
11
+ import { loadSkills } from './skills/loader.js';
12
+ import { ProcessManager } from './process.js';
13
+ import { secureWriteFile } from './security.js';
14
+ async function ensureInitialized(force = false) {
15
+ const { access, cp, mkdir, realpath } = await import('node:fs/promises');
16
+ const { dirname, resolve } = await import('node:path');
17
+ const { homedir } = await import('node:os');
18
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const home = await realpath(resolve(homedir()));
20
+ const configDir = resolve(home, '.localmcp');
21
+ const target = resolve(configDir, 'localmcp.json');
22
+ const skillsTarget = resolve(configDir, 'skills');
23
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
24
+ let created = false;
25
+ try {
26
+ await access(target);
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 'ENOENT')
30
+ throw error;
31
+ let workspaceRoot = await realpath(resolve(process.cwd()));
32
+ const sameHome = process.platform === 'win32'
33
+ ? workspaceRoot.toLowerCase() === home.toLowerCase()
34
+ : workspaceRoot === home;
35
+ if (sameHome) {
36
+ workspaceRoot = resolve(configDir, 'workspace');
37
+ await mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
38
+ }
39
+ const initialConfig = {
40
+ workspaces: {
41
+ project: workspaceRoot
42
+ },
43
+ defaultWorkspace: 'project',
44
+ features: {
45
+ files: {
46
+ read: true,
47
+ write: false,
48
+ delete: false
49
+ },
50
+ shell: false,
51
+ processes: false,
52
+ externalMcp: false
53
+ },
54
+ skills: {
55
+ dir: 'skills',
56
+ enabled: ['local-development']
57
+ },
58
+ mcpServers: {}
59
+ };
60
+ await secureWriteFile(target, JSON.stringify(initialConfig, null, 2) + '\n');
61
+ created = true;
62
+ }
63
+ try {
64
+ await access(skillsTarget);
65
+ }
66
+ catch (error) {
67
+ if (error.code !== 'ENOENT')
68
+ throw error;
69
+ await cp(resolve(packageRoot, 'skills'), skillsTarget, { recursive: true });
70
+ created = true;
71
+ }
72
+ if (created || force) {
73
+ console.log(created
74
+ ? `Initialized ${configDir} with secure defaults.`
75
+ : `Already initialized: ${configDir}`);
76
+ }
77
+ }
78
+ function parseUnlockMinutes(args) {
79
+ const index = args.indexOf('--minutes');
80
+ if (index < 0)
81
+ return 30;
82
+ const raw = args[index + 1];
83
+ const minutes = Number(raw);
84
+ if (!raw || !Number.isInteger(minutes) || minutes < 1 || minutes > 480) {
85
+ throw new Error('--minutes must be an integer from 1 to 480');
86
+ }
87
+ return minutes;
88
+ }
89
+ async function main() {
90
+ const mode = process.argv[2] || 'start';
91
+ if (mode === 'init') {
92
+ await ensureInitialized(true);
93
+ return;
94
+ }
95
+ if (mode === 'ui' || mode === 'desktop' || mode === 'tray' || mode === 'desktop-host') {
96
+ await ensureInitialized();
97
+ const { startControlUi } = await import('./control-ui.js');
98
+ const rawPort = process.env.LOCALMCP_UI_PORT;
99
+ const port = rawPort === undefined ? 0 : Number(rawPort);
100
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
101
+ throw new Error('LOCALMCP_UI_PORT must be an integer from 0 to 65535');
102
+ }
103
+ const desktop = mode === 'desktop';
104
+ const tray = mode === 'tray';
105
+ const desktopHost = mode === 'desktop-host';
106
+ const ui = await startControlUi({
107
+ port,
108
+ openBrowser: !desktop && !tray && !desktopHost && !process.argv.slice(3).includes('--no-open')
109
+ });
110
+ if (tray) {
111
+ const { startNativeTray } = await import('./tray.js');
112
+ const session = await startNativeTray(ui.url);
113
+ console.log(`Easy Local MCP tray control: ${ui.url}`);
114
+ console.log(`Tray binary: ${session.command}`);
115
+ const close = () => {
116
+ session.child.kill();
117
+ void ui.close();
118
+ };
119
+ process.once('SIGINT', close);
120
+ process.once('SIGTERM', close);
121
+ const outcome = await Promise.race([
122
+ session.closed.then(code => ({ kind: 'tray', code })),
123
+ ui.closed.then(() => ({ kind: 'ui' }))
124
+ ]);
125
+ if (outcome.kind === 'ui') {
126
+ session.child.kill();
127
+ await session.closed.catch(() => null);
128
+ }
129
+ else {
130
+ await ui.close();
131
+ if (outcome.code !== 0) {
132
+ throw new Error(`Easy Local MCP tray exited with code ${outcome.code ?? 'unknown'}`);
133
+ }
134
+ }
135
+ return;
136
+ }
137
+ if (desktopHost) {
138
+ console.log(`LOCALMCP_CONTROL_URL=${ui.url}`);
139
+ }
140
+ else if (desktop) {
141
+ const { openDesktopControl } = await import('./desktop.js');
142
+ const launched = await openDesktopControl(ui.url);
143
+ console.log(`Easy Local MCP desktop control: ${ui.url}`);
144
+ console.log(`Desktop window: ${launched.mode} via ${launched.command}`);
145
+ }
146
+ else {
147
+ console.log(`Easy Local MCP control UI: ${ui.url}`);
148
+ }
149
+ const close = () => {
150
+ void ui.close();
151
+ };
152
+ process.once('SIGINT', close);
153
+ process.once('SIGTERM', close);
154
+ await ui.closed;
155
+ return;
156
+ }
157
+ if (['start', 'stop', 'reload', 'status', 'url', 'unlock', 'lock', 'rotate'].includes(mode)) {
158
+ const { control, status, printStatus, printUrl, request } = await import('./lifecycle.js');
159
+ if (mode === 'status') {
160
+ printStatus(await status());
161
+ return;
162
+ }
163
+ if (mode === 'url') {
164
+ printUrl(await status());
165
+ return;
166
+ }
167
+ if (mode === 'unlock') {
168
+ const current = await status();
169
+ if (current.status !== 'running') {
170
+ throw new Error('Easy Local MCP is stopped; start it before unlocking');
171
+ }
172
+ const minutes = parseUnlockMinutes(process.argv.slice(3));
173
+ printStatus(await request('unlock', { minutes }));
174
+ return;
175
+ }
176
+ if (mode === 'lock') {
177
+ const current = await status();
178
+ if (current.status !== 'running') {
179
+ throw new Error('Easy Local MCP is stopped');
180
+ }
181
+ printStatus(await request('lock'));
182
+ return;
183
+ }
184
+ if (mode === 'rotate') {
185
+ const current = await status();
186
+ if (current.status !== 'running') {
187
+ throw new Error('Easy Local MCP is stopped');
188
+ }
189
+ printStatus(await request('rotate'));
190
+ return;
191
+ }
192
+ await control(mode, ensureInitialized);
193
+ return;
194
+ }
195
+ if (mode === 'agent') {
196
+ await ensureInitialized();
197
+ await import('./agent.js');
198
+ return;
199
+ }
200
+ const cfg = await config();
201
+ if (!['stdio', 'http'].includes(mode)) {
202
+ throw new Error('Usage: easy-local-mcp [start|status|url|unlock|lock|rotate|stop|reload|ui|desktop|tray|init|agent|stdio|http]');
203
+ }
204
+ if (mode === 'http' && (!cfg.token || cfg.token.length < 32)) {
205
+ throw new Error('HTTP requires LOCALMCP_TOKEN with at least 32 characters');
206
+ }
207
+ const mcp = new McpLoader(cfg.mcpServers);
208
+ const skills = await loadSkills(cfg.skillsDir, cfg.enabledSkills);
209
+ const processes = new ProcessManager();
210
+ let runtime = {
211
+ config: cfg,
212
+ mcp,
213
+ skills
214
+ };
215
+ const retired = new Set();
216
+ const closeWatcher = watchConfig(configFilePath(), async (content) => {
217
+ const nextConfig = await config({
218
+ content,
219
+ path: configFilePath()
220
+ });
221
+ if (JSON.stringify(nextConfig) === JSON.stringify(runtime.config)) {
222
+ return;
223
+ }
224
+ const nextSkills = await loadSkills(nextConfig.skillsDir, nextConfig.enabledSkills);
225
+ const changedMcp = JSON.stringify(nextConfig.mcpServers)
226
+ !== JSON.stringify(runtime.config.mcpServers);
227
+ const nextMcp = changedMcp
228
+ ? new McpLoader(nextConfig.mcpServers)
229
+ : runtime.mcp;
230
+ const previous = runtime;
231
+ runtime = {
232
+ config: nextConfig,
233
+ mcp: nextMcp,
234
+ skills: nextSkills
235
+ };
236
+ if (changedMcp) {
237
+ const closing = previous.mcp
238
+ .close()
239
+ .finally(() => retired.delete(closing));
240
+ retired.add(closing);
241
+ }
242
+ console.error('Easy Local MCP configuration hot-reloaded.');
243
+ }, error => console.error('Easy Local MCP config hot-reload rejected; keeping current configuration:', error instanceof Error ? error.message : String(error)));
244
+ const shutdown = [];
245
+ if (mode === 'stdio') {
246
+ const server = await createServer(cfg, mcp, skills, processes, () => runtime);
247
+ await server.connect(new StdioServerTransport());
248
+ shutdown.push(() => server.close());
249
+ }
250
+ else {
251
+ const app = express();
252
+ app.disable('x-powered-by');
253
+ app.get('/healthz', (_req, res) => {
254
+ res.json({ ok: true });
255
+ });
256
+ app.use((req, res, next) => {
257
+ if (req.headers.origin) {
258
+ res.sendStatus(403);
259
+ return;
260
+ }
261
+ const pathToken = /^\/mcp\/([A-Za-z0-9_-]+)$/.exec(req.path)?.[1];
262
+ const supplied = Buffer.from(pathToken
263
+ ? `Bearer ${pathToken}`
264
+ : req.headers.authorization || '');
265
+ const expected = Buffer.from(`Bearer ${cfg.token}`);
266
+ if (supplied.length !== expected.length
267
+ || !timingSafeEqual(supplied, expected)) {
268
+ res.sendStatus(404);
269
+ return;
270
+ }
271
+ res.setHeader('Cache-Control', 'no-store');
272
+ next();
273
+ });
274
+ app.use(express.json({ limit: '2mb' }));
275
+ let gate = Promise.resolve();
276
+ app.post(['/mcp', '/mcp/:token'], async (req, res) => {
277
+ const previous = gate;
278
+ let release;
279
+ gate = new Promise(resolveGate => {
280
+ release = resolveGate;
281
+ });
282
+ await previous;
283
+ let server;
284
+ let transport;
285
+ try {
286
+ const active = runtime;
287
+ server = await createServer(active.config, active.mcp, active.skills, processes, () => runtime);
288
+ transport = new StreamableHTTPServerTransport({
289
+ sessionIdGenerator: undefined,
290
+ enableJsonResponse: true
291
+ });
292
+ await server.connect(transport);
293
+ await transport.handleRequest(req, res, req.body);
294
+ }
295
+ catch {
296
+ if (!res.headersSent) {
297
+ res.status(500).json({
298
+ error: 'MCP request failed'
299
+ });
300
+ }
301
+ }
302
+ finally {
303
+ await transport?.close();
304
+ await server?.close();
305
+ release();
306
+ }
307
+ });
308
+ app.all(['/mcp', '/mcp/:token'], (_req, res) => {
309
+ res.setHeader('Allow', 'POST');
310
+ res.sendStatus(405);
311
+ });
312
+ const listener = app.listen(cfg.port, '127.0.0.1', () => {
313
+ if (process.env.LOCALMCP_INTERNAL !== '1') {
314
+ console.error(`Easy Local MCP listening on http://127.0.0.1:${cfg.port}/mcp`);
315
+ }
316
+ });
317
+ listener.on('error', error => {
318
+ console.error(error.message);
319
+ process.exit(1);
320
+ });
321
+ shutdown.push(() => new Promise(resolveClose => listener.close(() => resolveClose())));
322
+ }
323
+ const stop = async () => {
324
+ await closeWatcher();
325
+ for (const fn of shutdown) {
326
+ await fn();
327
+ }
328
+ await processes.close();
329
+ await runtime.mcp.close();
330
+ await Promise.allSettled([...retired]);
331
+ process.exit(0);
332
+ };
333
+ process.once('SIGINT', stop);
334
+ process.once('SIGTERM', stop);
335
+ }
336
+ main().catch(error => {
337
+ console.error(error.message);
338
+ process.exit(1);
339
+ });