ultra-lean-mcp-proxy 0.3.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,530 @@
1
+ /**
2
+ * Ultra Lean MCP Proxy - config file watcher.
3
+ *
4
+ * Watches discovered MCP client config files for changes and automatically
5
+ * wraps new unwrapped stdio server entries to route through the proxy.
6
+ *
7
+ * Uses fs.watchFile (polling) for cross-platform reliability.
8
+ * Includes file locking with PID-based stale lock recovery.
9
+ *
10
+ * Zero npm dependencies - uses only Node.js built-ins.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { spawn, spawnSync } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import {
19
+ acquireConfigLock,
20
+ backupConfig,
21
+ getConfigLocations,
22
+ isProcessAlive,
23
+ readConfig,
24
+ releaseConfigLock,
25
+ isWrapped,
26
+ isStdioServer,
27
+ isUrlServer,
28
+ wrapEntry,
29
+ wrapUrlEntry,
30
+ writeConfigAtomic,
31
+ resolveProxyPath,
32
+ isUrlBridgeAvailable,
33
+ parseClaudeMcpListCloudConnectors,
34
+ parseClaudeMcpListNames,
35
+ parseClaudeMcpGetDetails,
36
+ isClaudeCloudScope,
37
+ isClaudeLocalScope,
38
+ isSafePropertyName,
39
+ normalizeClientName,
40
+ } from './installer.mjs';
41
+
42
+ const CONFIG_DIR = path.join(os.homedir(), '.ultra-lean-mcp-proxy');
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Helpers for cloud discovery
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Check if a command exists on the system PATH (watcher-safe: never throws).
50
+ *
51
+ * @param {string} name - Command name to check
52
+ * @returns {boolean}
53
+ */
54
+ function commandExistsWatcher(name) {
55
+ const locator = process.platform === 'win32' ? 'where' : 'which';
56
+ try {
57
+ const result = spawnSync(locator, [name], {
58
+ stdio: 'ignore',
59
+ timeout: 5000,
60
+ });
61
+ return result.status === 0;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Run a claude mcp command and return stdout (watcher-safe: never throws).
69
+ *
70
+ * @param {string[]} args - Arguments to pass to `claude mcp`
71
+ * @returns {string|null} - stdout on success, null on failure
72
+ */
73
+ function cleanEnvForClaude() {
74
+ const blocked = new Set(['CLAUDECODE', 'CLAUDE_CODE']);
75
+ const env = {};
76
+ for (const [key, value] of Object.entries(process.env)) {
77
+ if (!blocked.has(key)) {
78
+ env[key] = value;
79
+ }
80
+ }
81
+ return env;
82
+ }
83
+
84
+ function runClaudeMcpCommandWatcher(args) {
85
+ try {
86
+ const result = spawnSync('claude', ['mcp', ...args], {
87
+ encoding: 'utf-8',
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ timeout: 60000,
90
+ env: cleanEnvForClaude(),
91
+ });
92
+ if (result.error || result.status !== 0) {
93
+ return null;
94
+ }
95
+ return String(result.stdout || '');
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Discover cloud connectors from `claude mcp` and merge them into the target config.
103
+ * Watcher-safe: logs warnings but never crashes.
104
+ *
105
+ * @param {string} proxyPath - Absolute path to the proxy binary
106
+ * @param {string} runtime - Runtime identifier
107
+ * @param {string} suffix - Suffix to append to server names
108
+ * @param {boolean} verbose - Verbose logging
109
+ */
110
+ async function discoverCloudConnectors(proxyPath, runtime, suffix, verbose) {
111
+ try {
112
+ // List all MCP servers
113
+ const listOutput = runClaudeMcpCommandWatcher(['list']);
114
+ if (!listOutput) {
115
+ if (verbose) {
116
+ process.stderr.write('[watcher] cloud-discovery: failed to run `claude mcp list`\n');
117
+ }
118
+ return;
119
+ }
120
+
121
+ const names = parseClaudeMcpListNames(listOutput);
122
+ const cloudConnectors = parseClaudeMcpListCloudConnectors(listOutput);
123
+ if (names.length === 0 && cloudConnectors.length === 0) {
124
+ if (verbose) {
125
+ process.stderr.write('[watcher] cloud-discovery: no servers found\n');
126
+ }
127
+ return;
128
+ }
129
+
130
+ // List-then-get flow for standard servers
131
+ const candidates = [];
132
+ for (const name of names) {
133
+ const getOutput = runClaudeMcpCommandWatcher(['get', name]);
134
+ if (!getOutput) {
135
+ if (verbose) {
136
+ process.stderr.write(`[watcher] cloud-discovery: failed to get details for "${name}"\n`);
137
+ }
138
+ continue;
139
+ }
140
+
141
+ const details = parseClaudeMcpGetDetails(getOutput);
142
+
143
+ // Filter: skip local scope
144
+ if (isClaudeLocalScope(details.scope)) {
145
+ continue;
146
+ }
147
+
148
+ // Filter: skip unknown scope
149
+ if (!isClaudeCloudScope(details.scope)) {
150
+ if (verbose) {
151
+ process.stderr.write(`[watcher] cloud-discovery: "${name}" has unknown scope: ${details.scope || 'empty'}\n`);
152
+ }
153
+ continue;
154
+ }
155
+
156
+ // Filter: only URL transports
157
+ const transport = String(details.type || '').toLowerCase();
158
+ if (!['sse', 'http', 'streamable-http'].includes(transport)) {
159
+ continue;
160
+ }
161
+
162
+ // Filter: must have URL
163
+ if (!details.url) {
164
+ if (verbose) {
165
+ process.stderr.write(`[watcher] cloud-discovery: "${name}" is missing URL\n`);
166
+ }
167
+ continue;
168
+ }
169
+
170
+ // Build target name and validate
171
+ const targetName = `${name}${suffix}`;
172
+ if (!isSafePropertyName(targetName)) {
173
+ if (verbose) {
174
+ process.stderr.write(`[watcher] cloud-discovery: target name "${targetName}" is not safe\n`);
175
+ }
176
+ continue;
177
+ }
178
+
179
+ // Build wrapped entry
180
+ const sourceEntry = {
181
+ url: details.url,
182
+ transport,
183
+ };
184
+ if (details.headers && Object.keys(details.headers).length > 0) {
185
+ sourceEntry.headers = details.headers;
186
+ }
187
+
188
+ const wrappedEntry = wrapUrlEntry(sourceEntry, proxyPath, runtime);
189
+ candidates.push({ targetName, wrappedEntry });
190
+ }
191
+
192
+ // Cloud connector entries parsed directly from list output
193
+ const candidateTargetNames = new Set(candidates.map((c) => c.targetName));
194
+ for (const cc of cloudConnectors) {
195
+ const targetName = `${cc.safeName}${suffix}`;
196
+ if (!isSafePropertyName(targetName)) {
197
+ if (verbose) {
198
+ process.stderr.write(`[watcher] cloud-discovery: target name "${targetName}" is not safe\n`);
199
+ }
200
+ continue;
201
+ }
202
+ if (candidateTargetNames.has(targetName)) {
203
+ if (verbose) {
204
+ process.stderr.write(`[watcher] cloud-discovery: "${cc.displayName}" already collected via get\n`);
205
+ }
206
+ continue;
207
+ }
208
+
209
+ const sourceEntry = {
210
+ url: cc.url,
211
+ transport: cc.transport,
212
+ };
213
+ const wrappedEntry = wrapUrlEntry(sourceEntry, proxyPath, runtime);
214
+ candidates.push({ targetName, wrappedEntry });
215
+ candidateTargetNames.add(targetName);
216
+ }
217
+
218
+ if (candidates.length === 0) {
219
+ if (verbose) {
220
+ process.stderr.write('[watcher] cloud-discovery: no cloud URL connectors to wrap\n');
221
+ }
222
+ return;
223
+ }
224
+
225
+ // Find claude-code-user config location
226
+ const locations = await getConfigLocations(true);
227
+ const targetLoc = locations.find((loc) => normalizeClientName(loc.name) === 'claude-code-user') || {
228
+ name: 'claude-code-user',
229
+ path: path.join(os.homedir(), '.claude.json'),
230
+ serverKey: 'mcpServers',
231
+ };
232
+ const configPath = targetLoc.path;
233
+ const serverKey = targetLoc.serverKey || 'mcpServers';
234
+
235
+ // Lock, read, merge, write
236
+ if (!acquireConfigLock(configPath)) {
237
+ if (verbose) {
238
+ process.stderr.write('[watcher] cloud-discovery: could not acquire lock\n');
239
+ }
240
+ return;
241
+ }
242
+
243
+ try {
244
+ let config = {};
245
+ if (fs.existsSync(configPath)) {
246
+ config = readConfig(configPath);
247
+ if (!config || typeof config !== 'object') {
248
+ config = {};
249
+ }
250
+ }
251
+
252
+ if (!config[serverKey] || typeof config[serverKey] !== 'object') {
253
+ config[serverKey] = {};
254
+ }
255
+ const servers = config[serverKey];
256
+
257
+ let changed = false;
258
+ for (const candidate of candidates) {
259
+ const existing = servers[candidate.targetName];
260
+ // JSON equality check
261
+ if (existing && JSON.stringify(existing) === JSON.stringify(candidate.wrappedEntry)) {
262
+ continue; // unchanged
263
+ }
264
+
265
+ servers[candidate.targetName] = candidate.wrappedEntry;
266
+ changed = true;
267
+ process.stderr.write(`[watcher] cloud-discovery: updated "${candidate.targetName}"\n`);
268
+ }
269
+
270
+ if (changed) {
271
+ backupConfig(configPath);
272
+ writeConfigAtomic(configPath, config);
273
+ if (verbose) {
274
+ process.stderr.write(`[watcher] cloud-discovery: config saved: ${configPath}\n`);
275
+ }
276
+ }
277
+ } finally {
278
+ releaseConfigLock(configPath);
279
+ }
280
+ } catch (err) {
281
+ process.stderr.write(`[watcher] cloud-discovery: error: ${err.message}\n`);
282
+ }
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Config change handler
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Handle a detected change to a config file. Reads the config, wraps any
291
+ * unwrapped stdio servers, and writes back atomically under a file lock.
292
+ *
293
+ * @param {{name: string, path: string, serverKey: string}} loc
294
+ * @param {string} proxyPath
295
+ * @param {string} runtime
296
+ * @param {boolean} wrapUrl
297
+ * @param {boolean} canWrapUrl
298
+ * @param {boolean} verbose
299
+ */
300
+ function handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose) {
301
+ const configPath = loc.path;
302
+ const serverKey = loc.serverKey || 'mcpServers';
303
+
304
+ if (!fs.existsSync(configPath)) {
305
+ return;
306
+ }
307
+
308
+ if (!acquireConfigLock(configPath)) {
309
+ if (verbose) {
310
+ process.stderr.write(`[watcher] ${loc.name}: could not acquire lock, skipping\n`);
311
+ }
312
+ return;
313
+ }
314
+
315
+ try {
316
+ const config = readConfig(configPath);
317
+ if (!config || typeof config !== 'object') return;
318
+
319
+ const servers = config[serverKey];
320
+ if (!servers || typeof servers !== 'object') return;
321
+
322
+ let changed = false;
323
+ for (const [name, entry] of Object.entries(servers)) {
324
+ const stdio = isStdioServer(entry);
325
+ const url = isUrlServer(entry);
326
+ if (!stdio && !url) continue;
327
+ if (isWrapped(entry)) continue;
328
+ if (url && !wrapUrl) continue;
329
+ if (url && !canWrapUrl) {
330
+ if (verbose) {
331
+ process.stderr.write(`[watcher] ${loc.name}: bridge unavailable for "${name}" (npx missing)\n`);
332
+ }
333
+ continue;
334
+ }
335
+
336
+ servers[name] = url
337
+ ? wrapUrlEntry(entry, proxyPath, runtime)
338
+ : wrapEntry(entry, proxyPath, runtime);
339
+ changed = true;
340
+ process.stderr.write(`[watcher] ${loc.name}: wrapped "${name}" (${url ? 'url' : 'stdio'})\n`);
341
+ }
342
+
343
+ if (changed) {
344
+ backupConfig(configPath);
345
+ writeConfigAtomic(configPath, config);
346
+ }
347
+ } catch (err) {
348
+ process.stderr.write(`[watcher] ${loc.name}: error processing config: ${err.message}\n`);
349
+ } finally {
350
+ releaseConfigLock(configPath);
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Watch loop
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Start watching config files for changes. Performs an initial scan and then
360
+ * polls files at the given interval.
361
+ *
362
+ * @param {object} options
363
+ * @param {number} options.interval Polling interval in seconds (default: 5)
364
+ * @param {string} options.runtime Runtime identifier (default: 'npm')
365
+ * @param {boolean} options.offline Skip remote registry fetch (default: false)
366
+ * @param {boolean} options.wrapUrl Wrap URL entries too (default: true)
367
+ * @param {boolean} options.verbose Verbose logging (default: false)
368
+ * @param {string} options.suffix Suffix for cloud connector names (default: '-ulmp')
369
+ * @param {number} options.cloudInterval Cloud discovery interval in seconds (default: 60)
370
+ */
371
+ export async function runWatch(options = {}) {
372
+ const { interval = 5, runtime = 'npm', offline = false, wrapUrl = true, verbose = false, suffix = '-ulmp', cloudInterval = 60 } = options;
373
+
374
+ const proxyPath = resolveProxyPath();
375
+ const locations = await getConfigLocations(offline);
376
+ const canWrapUrl = wrapUrl ? isUrlBridgeAvailable() : false;
377
+ if (wrapUrl && !canWrapUrl) {
378
+ process.stderr.write('[watcher] URL wrapping enabled but `npx` is unavailable; URL entries will be skipped.\n');
379
+ }
380
+ let watchCount = 0;
381
+
382
+ // Check for claude CLI
383
+ const claudeAvailable = commandExistsWatcher('claude');
384
+ if (claudeAvailable) {
385
+ process.stderr.write('[watcher] claude CLI found - cloud discovery enabled\n');
386
+ } else {
387
+ if (verbose) {
388
+ process.stderr.write('[watcher] claude CLI not found - cloud discovery disabled\n');
389
+ }
390
+ }
391
+
392
+ // Initial scan
393
+ for (const loc of locations) {
394
+ if (fs.existsSync(loc.path)) {
395
+ handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose);
396
+ }
397
+ }
398
+
399
+ // Initial cloud discovery
400
+ if (claudeAvailable) {
401
+ await discoverCloudConnectors(proxyPath, runtime, suffix, verbose);
402
+ }
403
+
404
+ // Set up polling watchers (including currently-missing paths, so creation is detected)
405
+ for (const loc of locations) {
406
+ fs.watchFile(loc.path, { interval: interval * 1000 }, (curr, prev) => {
407
+ if (curr.mtimeMs === prev.mtimeMs) return;
408
+ if (verbose) {
409
+ process.stderr.write(`[watcher] ${loc.name}: change detected\n`);
410
+ }
411
+ handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose);
412
+ });
413
+ watchCount++;
414
+ }
415
+
416
+ process.stderr.write(
417
+ `[ultra-lean-mcp-proxy] Watching ${watchCount} config files (interval: ${interval}s)\n`
418
+ );
419
+
420
+ // Keep the process alive
421
+ const keepAlive = setInterval(() => {}, 60000);
422
+
423
+ // Cloud discovery interval
424
+ let cloudDiscoveryInterval = null;
425
+ if (claudeAvailable) {
426
+ cloudDiscoveryInterval = setInterval(() => {
427
+ discoverCloudConnectors(proxyPath, runtime, suffix, verbose);
428
+ }, cloudInterval * 1000);
429
+ }
430
+
431
+ // Cleanup on exit
432
+ function cleanup() {
433
+ clearInterval(keepAlive);
434
+ if (cloudDiscoveryInterval) {
435
+ clearInterval(cloudDiscoveryInterval);
436
+ }
437
+ for (const loc of locations) {
438
+ try { fs.unwatchFile(loc.path); } catch { /* ignore */ }
439
+ }
440
+ }
441
+
442
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
443
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
444
+
445
+ // Return a promise that never resolves (watcher runs until killed)
446
+ return new Promise(() => {});
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Daemon management
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /**
454
+ * Start the watcher as a detached background daemon. Writes its PID to
455
+ * ~/.ultra-lean-mcp-proxy/watch.pid and logs to watch.log.
456
+ *
457
+ * @param {object} options
458
+ * @param {number} options.interval Polling interval in seconds
459
+ * @param {string} options.runtime Runtime identifier
460
+ * @param {boolean} options.offline Skip remote registry
461
+ * @param {boolean} options.wrapUrl Wrap URL entries
462
+ * @param {boolean} options.verbose Verbose logging
463
+ * @param {string} options.suffix Suffix for cloud connector names
464
+ * @param {number} options.cloudInterval Cloud discovery interval in seconds
465
+ */
466
+ export function startDaemon(options = {}) {
467
+ const { interval = 5, runtime = 'npm', offline = false, wrapUrl = true, verbose = false, suffix = '-ulmp', cloudInterval = 60 } = options;
468
+
469
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
470
+ const pidFile = path.join(CONFIG_DIR, 'watch.pid');
471
+ const logFile = path.join(CONFIG_DIR, 'watch.log');
472
+
473
+ // Check if a daemon is already running
474
+ if (fs.existsSync(pidFile)) {
475
+ try {
476
+ const existingPid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
477
+ if (isProcessAlive(existingPid)) {
478
+ process.stderr.write(`[ultra-lean-mcp-proxy] Daemon already running (PID: ${existingPid})\n`);
479
+ return;
480
+ }
481
+ // Stale PID file - clean up
482
+ fs.unlinkSync(pidFile);
483
+ } catch { /* ignore */ }
484
+ }
485
+
486
+ // Resolve CLI path from this module's location
487
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
488
+ const cliPath = path.join(thisDir, '..', 'bin', 'cli.mjs');
489
+
490
+ const childArgs = [
491
+ cliPath,
492
+ 'watch',
493
+ '--interval', String(interval),
494
+ '--runtime', String(runtime),
495
+ '--suffix', String(suffix),
496
+ '--cloud-interval', String(cloudInterval),
497
+ ...(offline ? ['--offline'] : []),
498
+ ...(wrapUrl ? [] : ['--no-wrap-url']),
499
+ ...(verbose ? ['-v'] : []),
500
+ ];
501
+
502
+ const logFd = fs.openSync(logFile, 'a');
503
+ const child = spawn(process.execPath, childArgs, {
504
+ detached: true,
505
+ stdio: ['ignore', logFd, logFd],
506
+ });
507
+
508
+ fs.writeFileSync(pidFile, String(child.pid));
509
+ child.unref();
510
+ fs.closeSync(logFd);
511
+
512
+ process.stderr.write(`[ultra-lean-mcp-proxy] Daemon started (PID: ${child.pid})\n`);
513
+ process.stderr.write(`[ultra-lean-mcp-proxy] Log file: ${logFile}\n`);
514
+ }
515
+
516
+ /**
517
+ * Stop a running watcher daemon by reading its PID file and sending SIGTERM.
518
+ */
519
+ export function stopDaemon() {
520
+ const pidFile = path.join(CONFIG_DIR, 'watch.pid');
521
+
522
+ try {
523
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
524
+ process.kill(pid, 'SIGTERM');
525
+ fs.unlinkSync(pidFile);
526
+ process.stderr.write(`[ultra-lean-mcp-proxy] Daemon stopped (PID: ${pid})\n`);
527
+ } catch (err) {
528
+ process.stderr.write(`[ultra-lean-mcp-proxy] No daemon running or could not stop: ${err.message}\n`);
529
+ }
530
+ }