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,1756 @@
1
+ /**
2
+ * Installer for Ultra Lean MCP Proxy (Node.js).
3
+ *
4
+ * Discovers MCP client config files, wraps / unwraps server entries to route
5
+ * through the proxy, and handles npx auto-install.
6
+ *
7
+ * Zero npm dependencies - uses only Node.js built-ins.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import { execSync, spawnSync } from 'node:child_process';
14
+ import https from 'node:https';
15
+ import http from 'node:http';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/lean-agent-protocol/ultra-lean-mcp-proxy/main/registry/clients.json';
22
+ const REGISTRY_TIMEOUT_MS = 3000;
23
+ const REGISTRY_MAX_BYTES = 65536;
24
+ const CONFIG_DIR_NAME = '.ultra-lean-mcp-proxy';
25
+ const BACKUP_DIR_NAME = '.ultra-lean-mcp-proxy-backups';
26
+ const LOCK_RETRIES = 5;
27
+ const LOCK_BACKOFF_MS = 200;
28
+ const SAFE_PATH_PREFIXES = ['~', '%APPDATA%', '%USERPROFILE%', '$HOME'];
29
+ const CLAUDE_LOCAL_SCOPE_PATTERN = /\b(local|user|project)\s+config\b/i;
30
+ const CLAUDE_CLOUD_SCOPE_PATTERN = /\bcloud\b/i;
31
+ const SAFE_PROPERTY_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
32
+ const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
33
+
34
+ /**
35
+ * Validate that a name is safe for use as an object property key.
36
+ * Rejects prototype pollution vectors and invalid characters.
37
+ *
38
+ * @param {string} name
39
+ * @returns {boolean}
40
+ */
41
+ export function isSafePropertyName(name) {
42
+ if (typeof name !== 'string' || !name) return false;
43
+ if (UNSAFE_PROPERTY_NAMES.has(name)) return false;
44
+ return SAFE_PROPERTY_NAME_PATTERN.test(name);
45
+ }
46
+
47
+ function sleepMs(ms) {
48
+ try {
49
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
50
+ } catch {
51
+ const start = Date.now();
52
+ while (Date.now() - start < ms) {
53
+ // busy-wait fallback for environments without SAB
54
+ }
55
+ }
56
+ }
57
+
58
+ export function normalizeClientName(name) {
59
+ return String(name || '')
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[()]/g, '')
63
+ .replace(/[_\s]+/g, '-');
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Platform-aware config locations
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Return the hardcoded default config file paths for known MCP clients on the
72
+ * current platform.
73
+ *
74
+ * @returns {Array<{name: string, path: string, serverKey: string}>}
75
+ */
76
+ function getDefaultLocations() {
77
+ const platform = process.platform; // win32 | darwin | linux
78
+ const home = os.homedir();
79
+ const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
80
+ const userprofile = process.env.USERPROFILE || home;
81
+
82
+ const locations = [];
83
+
84
+ // Claude Desktop
85
+ if (platform === 'win32') {
86
+ locations.push({
87
+ name: 'claude-desktop',
88
+ path: path.join(appdata, 'Claude', 'claude_desktop_config.json'),
89
+ serverKey: 'mcpServers',
90
+ });
91
+ } else if (platform === 'darwin') {
92
+ locations.push({
93
+ name: 'claude-desktop',
94
+ path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
95
+ serverKey: 'mcpServers',
96
+ });
97
+ } else {
98
+ locations.push({
99
+ name: 'claude-desktop',
100
+ path: path.join(home, '.config', 'claude', 'claude_desktop_config.json'),
101
+ serverKey: 'mcpServers',
102
+ });
103
+ }
104
+
105
+ // Claude Code (global settings)
106
+ locations.push({
107
+ name: 'claude-code',
108
+ path: path.join(home, '.claude', 'settings.json'),
109
+ serverKey: 'mcpServers',
110
+ });
111
+
112
+ // Claude Code (local settings)
113
+ locations.push({
114
+ name: 'claude-code-local',
115
+ path: path.join(home, '.claude', 'settings.local.json'),
116
+ serverKey: 'mcpServers',
117
+ });
118
+
119
+ // Claude Code (new user config used by `claude mcp add --scope user/local`)
120
+ locations.push({
121
+ name: 'claude-code-user',
122
+ path: path.join(home, '.claude.json'),
123
+ serverKey: 'mcpServers',
124
+ });
125
+
126
+ // Cursor
127
+ if (platform === 'win32') {
128
+ locations.push({
129
+ name: 'cursor',
130
+ path: path.join(userprofile, '.cursor', 'mcp.json'),
131
+ serverKey: 'mcpServers',
132
+ });
133
+ } else {
134
+ locations.push({
135
+ name: 'cursor',
136
+ path: path.join(home, '.cursor', 'mcp.json'),
137
+ serverKey: 'mcpServers',
138
+ });
139
+ }
140
+
141
+ // Windsurf
142
+ if (platform === 'win32') {
143
+ locations.push({
144
+ name: 'windsurf',
145
+ path: path.join(userprofile, '.codeium', 'windsurf', 'mcp_config.json'),
146
+ serverKey: 'mcpServers',
147
+ });
148
+ } else {
149
+ locations.push({
150
+ name: 'windsurf',
151
+ path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'),
152
+ serverKey: 'mcpServers',
153
+ });
154
+ }
155
+
156
+ return locations;
157
+ }
158
+
159
+ function isSafePathTemplate(rawPath) {
160
+ if (typeof rawPath !== 'string' || !rawPath) return false;
161
+ if (rawPath.includes('..')) return false;
162
+ if (/[^\x20-\x7E]/.test(rawPath)) return false;
163
+ return SAFE_PATH_PREFIXES.some((prefix) => rawPath.startsWith(prefix));
164
+ }
165
+
166
+ function expandPathTemplate(rawPath) {
167
+ let expanded = rawPath;
168
+ expanded = expanded.replaceAll('%APPDATA%', process.env.APPDATA || '');
169
+ expanded = expanded.replaceAll('%USERPROFILE%', process.env.USERPROFILE || os.homedir());
170
+ expanded = expanded.replaceAll('$HOME', os.homedir());
171
+ if (expanded.startsWith('~')) {
172
+ const suffix = expanded.slice(1).replace(/^[\\/]+/, '');
173
+ expanded = path.join(os.homedir(), suffix);
174
+ }
175
+ return expanded;
176
+ }
177
+
178
+ /**
179
+ * Fetch the remote client registry payload.
180
+ *
181
+ * @returns {Promise<object|Array| null>}
182
+ */
183
+ function fetchRemoteRegistry() {
184
+ return new Promise((resolve) => {
185
+ const protocol = REGISTRY_URL.startsWith('https') ? https : http;
186
+ const req = protocol.get(REGISTRY_URL, { timeout: REGISTRY_TIMEOUT_MS }, (res) => {
187
+ if (res.statusCode < 200 || res.statusCode >= 300) {
188
+ resolve(null);
189
+ res.resume();
190
+ return;
191
+ }
192
+
193
+ const chunks = [];
194
+ let totalBytes = 0;
195
+
196
+ res.on('data', (chunk) => {
197
+ totalBytes += chunk.length;
198
+ if (totalBytes > REGISTRY_MAX_BYTES) {
199
+ res.destroy();
200
+ resolve(null);
201
+ return;
202
+ }
203
+ chunks.push(chunk);
204
+ });
205
+
206
+ res.on('end', () => {
207
+ try {
208
+ const raw = Buffer.concat(chunks).toString('utf-8');
209
+ const parsed = JSON.parse(raw);
210
+ resolve(parsed);
211
+ } catch {
212
+ resolve(null);
213
+ }
214
+ });
215
+
216
+ res.on('error', () => resolve(null));
217
+ });
218
+
219
+ req.on('error', () => resolve(null));
220
+ req.on('timeout', () => {
221
+ req.destroy();
222
+ resolve(null);
223
+ });
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Read local overrides from ~/.ultra-lean-mcp-proxy/clients.json.
229
+ *
230
+ * @returns {Array}
231
+ */
232
+ function readLocalOverrides() {
233
+ const overridePath = path.join(os.homedir(), CONFIG_DIR_NAME, 'clients.json');
234
+ try {
235
+ const raw = fs.readFileSync(overridePath, 'utf-8');
236
+ const parsed = JSON.parse(raw);
237
+ if (Array.isArray(parsed)) return parsed;
238
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.clients)) return parsed.clients;
239
+ return [];
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Normalize registry-like entries into {name, path, serverKey} for this platform.
247
+ *
248
+ * Supports:
249
+ * - bare list: [{name, path, key?}, {name, paths:{...}, key?}]
250
+ * - versioned: {version, clients:[...]}
251
+ *
252
+ * @param {object|Array|null} payload
253
+ * @param {{strict?: boolean}} options
254
+ * @returns {Array<{name: string, path: string, serverKey: string}>}
255
+ */
256
+ export function normalizeRegistryEntries(payload, { strict = false } = {}) {
257
+ let clients;
258
+ if (Array.isArray(payload)) {
259
+ clients = payload;
260
+ } else if (payload && typeof payload === 'object' && Array.isArray(payload.clients)) {
261
+ clients = payload.clients;
262
+ } else {
263
+ return [];
264
+ }
265
+
266
+ const platform = process.platform;
267
+ const out = [];
268
+ const allowedKeys = new Set(['name', 'paths', 'path', 'key']);
269
+
270
+ for (const entry of clients) {
271
+ if (!entry || typeof entry !== 'object') continue;
272
+ if (strict) {
273
+ const keys = Object.keys(entry);
274
+ if (!keys.every((k) => allowedKeys.has(k))) continue;
275
+ }
276
+
277
+ const name = normalizeClientName(entry.name);
278
+ if (!name) continue;
279
+
280
+ let rawPath = null;
281
+ if (typeof entry.path === 'string') {
282
+ rawPath = entry.path;
283
+ } else if (entry.paths && typeof entry.paths === 'object') {
284
+ rawPath = entry.paths[platform] || null;
285
+ }
286
+
287
+ if (typeof rawPath !== 'string' || !rawPath) continue;
288
+ if (strict && !isSafePathTemplate(rawPath)) continue;
289
+
290
+ const expandedPath = expandPathTemplate(rawPath);
291
+ out.push({
292
+ name,
293
+ path: expandedPath,
294
+ serverKey: typeof entry.key === 'string' && entry.key ? entry.key : 'mcpServers',
295
+ });
296
+ }
297
+
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * Build the complete list of config locations by merging:
303
+ * 1. Hardcoded platform defaults
304
+ * 2. Remote registry (unless offline)
305
+ * 3. Local overrides
306
+ *
307
+ * Remote entries and local overrides can add new clients or override paths for
308
+ * existing ones (matched by name).
309
+ *
310
+ * @param {boolean} offline Skip remote registry fetch
311
+ * @returns {Promise<Array<{name: string, path: string, serverKey: string}>>}
312
+ */
313
+ export async function getConfigLocations(offline = false) {
314
+ const defaults = getDefaultLocations().map((loc) => ({
315
+ ...loc,
316
+ name: normalizeClientName(loc.name),
317
+ }));
318
+ const locations = defaults;
319
+
320
+ // Merge helper: upsert by name
321
+ function mergeIn(extras) {
322
+ for (const entry of extras) {
323
+ if (!entry.name || !entry.path) continue;
324
+ const normalized = {
325
+ ...entry,
326
+ name: normalizeClientName(entry.name),
327
+ path: expandPathTemplate(entry.path),
328
+ };
329
+ const idx = locations.findIndex((l) => l.name === normalized.name);
330
+ if (idx >= 0) {
331
+ locations[idx] = { ...locations[idx], ...normalized };
332
+ } else {
333
+ locations.push({
334
+ serverKey: 'mcpServers',
335
+ ...normalized,
336
+ });
337
+ }
338
+ }
339
+ }
340
+
341
+ // Remote registry
342
+ if (!offline) {
343
+ try {
344
+ const remotePayload = await fetchRemoteRegistry();
345
+ const remote = normalizeRegistryEntries(remotePayload, { strict: true });
346
+ mergeIn(remote);
347
+ } catch {
348
+ // fail silently
349
+ }
350
+ }
351
+
352
+ // Local overrides
353
+ mergeIn(normalizeRegistryEntries(readLocalOverrides(), { strict: false }));
354
+
355
+ return locations;
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // JSONC parser (strip comments)
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * Strip single-line (//) and multi-line comments from a JSONC string.
364
+ *
365
+ * Uses a character-by-character state machine that tracks whether we are inside
366
+ * a string literal (respecting escape sequences).
367
+ *
368
+ * @param {string} text
369
+ * @returns {string}
370
+ */
371
+ export function stripJsoncComments(text) {
372
+ const out = [];
373
+ let i = 0;
374
+ let inString = false;
375
+ let escape = false;
376
+
377
+ while (i < text.length) {
378
+ const ch = text[i];
379
+
380
+ if (escape) {
381
+ out.push(ch);
382
+ escape = false;
383
+ i++;
384
+ continue;
385
+ }
386
+
387
+ if (inString) {
388
+ if (ch === '\\') {
389
+ escape = true;
390
+ out.push(ch);
391
+ i++;
392
+ continue;
393
+ }
394
+ if (ch === '"') {
395
+ inString = false;
396
+ }
397
+ out.push(ch);
398
+ i++;
399
+ continue;
400
+ }
401
+
402
+ // Not in string
403
+ if (ch === '"') {
404
+ inString = true;
405
+ out.push(ch);
406
+ i++;
407
+ continue;
408
+ }
409
+
410
+ // Check for single-line comment
411
+ if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
412
+ // Skip to end of line
413
+ i += 2;
414
+ while (i < text.length && text[i] !== '\n') {
415
+ i++;
416
+ }
417
+ continue;
418
+ }
419
+
420
+ // Check for multi-line comment
421
+ if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
422
+ i += 2;
423
+ while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) {
424
+ i++;
425
+ }
426
+ i += 2; // skip closing */
427
+ continue;
428
+ }
429
+
430
+ out.push(ch);
431
+ i++;
432
+ }
433
+
434
+ return out.join('');
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Config read / write
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * Read and parse a config file. Supports JSONC (JSON with comments).
443
+ *
444
+ * @param {string} filePath
445
+ * @returns {object|null} Parsed config or null if file does not exist / is invalid.
446
+ */
447
+ export function readConfig(filePath) {
448
+ try {
449
+ const raw = fs.readFileSync(filePath, 'utf-8');
450
+ const stripped = stripJsoncComments(raw);
451
+ return JSON.parse(stripped);
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Atomically write JSON data to a file.
459
+ *
460
+ * Writes to a .tmp sibling first, then renames. On Windows, retries once with
461
+ * a 100ms delay if the rename fails (file locking).
462
+ *
463
+ * @param {string} filePath
464
+ * @param {object} data
465
+ */
466
+ export function writeConfigAtomic(filePath, data) {
467
+ const dir = path.dirname(filePath);
468
+ if (!fs.existsSync(dir)) {
469
+ fs.mkdirSync(dir, { recursive: true });
470
+ }
471
+
472
+ const tmpPath = filePath + '.tmp';
473
+ const content = JSON.stringify(data, null, 2) + '\n';
474
+ fs.writeFileSync(tmpPath, content, 'utf-8');
475
+
476
+ const attempts = process.platform === 'win32' ? 3 : 1;
477
+ let lastErr = null;
478
+ for (let attempt = 0; attempt < attempts; attempt++) {
479
+ try {
480
+ fs.renameSync(tmpPath, filePath);
481
+ return;
482
+ } catch (err) {
483
+ lastErr = err;
484
+ if (attempt < attempts - 1) {
485
+ sleepMs(100);
486
+ }
487
+ }
488
+ }
489
+
490
+ try {
491
+ fs.unlinkSync(tmpPath);
492
+ } catch {
493
+ // ignore cleanup failure
494
+ }
495
+ throw lastErr || new Error(`Failed to atomically write ${filePath}`);
496
+ }
497
+
498
+ /**
499
+ * Create a timestamped backup of a config file.
500
+ *
501
+ * @param {string} filePath
502
+ * @returns {string|null} Backup file path, or null if source does not exist.
503
+ */
504
+ export function backupConfig(filePath) {
505
+ if (!fs.existsSync(filePath)) {
506
+ return null;
507
+ }
508
+ const parent = path.dirname(filePath);
509
+ const backupDir = path.join(parent, BACKUP_DIR_NAME);
510
+ fs.mkdirSync(backupDir, { recursive: true });
511
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '') + 'Z';
512
+ const base = path.basename(filePath, path.extname(filePath));
513
+ const backupPath = path.join(backupDir, `${base}.${stamp}.bak`);
514
+ fs.copyFileSync(filePath, backupPath);
515
+ return backupPath;
516
+ }
517
+
518
+ export function isProcessAlive(pid) {
519
+ if (!Number.isInteger(pid) || pid <= 0) return false;
520
+ try {
521
+ process.kill(pid, 0);
522
+ return true;
523
+ } catch (err) {
524
+ if (err && (err.code === 'EPERM' || err.code === 'EACCES')) {
525
+ return true;
526
+ }
527
+ return false;
528
+ }
529
+ }
530
+
531
+ export function acquireConfigLock(configPath, retries = LOCK_RETRIES, backoffMs = LOCK_BACKOFF_MS) {
532
+ const lockPath = `${configPath}.lock`;
533
+ for (let attempt = 0; attempt < retries; attempt++) {
534
+ try {
535
+ const fd = fs.openSync(lockPath, 'wx');
536
+ fs.writeFileSync(fd, String(process.pid));
537
+ fs.closeSync(fd);
538
+ return true;
539
+ } catch (err) {
540
+ if (!err || err.code !== 'EEXIST') {
541
+ if (attempt < retries - 1) sleepMs(backoffMs);
542
+ continue;
543
+ }
544
+
545
+ try {
546
+ const ownerPid = parseInt(fs.readFileSync(lockPath, 'utf-8').trim(), 10);
547
+ if (!isProcessAlive(ownerPid)) {
548
+ fs.unlinkSync(lockPath);
549
+ continue;
550
+ }
551
+ } catch {
552
+ // unreadable lock file; retry
553
+ }
554
+
555
+ if (attempt < retries - 1) {
556
+ sleepMs(backoffMs);
557
+ }
558
+ }
559
+ }
560
+ return false;
561
+ }
562
+
563
+ export function releaseConfigLock(configPath) {
564
+ const lockPath = `${configPath}.lock`;
565
+ try {
566
+ fs.unlinkSync(lockPath);
567
+ } catch {
568
+ // ignore
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Lock a config file, read it, call fn(config), backup+write if fn returns an
574
+ * object, and unlock in a finally block.
575
+ *
576
+ * @param {string} configPath
577
+ * @param {(config: object) => object|null} fn Return a config object to write, or null to skip.
578
+ * @returns {object|null} The return value of fn.
579
+ */
580
+ export function withLockedConfig(configPath, fn) {
581
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
582
+ if (!acquireConfigLock(configPath)) {
583
+ throw new Error(`config is locked by another process: ${configPath}`);
584
+ }
585
+ try {
586
+ let config = {};
587
+ if (fs.existsSync(configPath)) {
588
+ config = readConfig(configPath);
589
+ if (!config || typeof config !== 'object') {
590
+ throw new Error(`could not parse target config: ${configPath}`);
591
+ }
592
+ }
593
+ const result = fn(config);
594
+ if (result && typeof result === 'object') {
595
+ if (fs.existsSync(configPath)) {
596
+ backupConfig(configPath);
597
+ }
598
+ writeConfigAtomic(configPath, result);
599
+ }
600
+ return result;
601
+ } finally {
602
+ releaseConfigLock(configPath);
603
+ }
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Wrap / unwrap detection
608
+ // ---------------------------------------------------------------------------
609
+
610
+ /**
611
+ * Determine whether a server entry is a stdio-based MCP server.
612
+ *
613
+ * @param {object} entry
614
+ * @returns {boolean}
615
+ */
616
+ export function isStdioServer(entry) {
617
+ if (typeof entry !== 'object' || entry === null) return false;
618
+ // Explicit transport type check
619
+ if (entry.transport === 'sse' || entry.transport === 'streamable-http') return false;
620
+ if (entry.url) return false; // SSE / HTTP transport
621
+ // Must have a command to be stdio
622
+ return typeof entry.command === 'string' && entry.command.length > 0;
623
+ }
624
+
625
+ export function isUrlServer(entry) {
626
+ if (typeof entry !== 'object' || entry === null) return false;
627
+ return typeof entry.url === 'string' && entry.url.length > 0;
628
+ }
629
+
630
+ function escapeCmdArg(value) {
631
+ return String(value || '')
632
+ .replace(/\^/g, '^^')
633
+ .replace(/[&|<>()!]/g, '^$&');
634
+ }
635
+
636
+ function bridgeCommandForUrl(url) {
637
+ const target = String(url || '').trim();
638
+ if (process.platform === 'win32') {
639
+ // cmd.exe treats URL metacharacters (e.g. &) as control tokens unless escaped.
640
+ return ['cmd', '/c', 'npx', '-y', 'mcp-remote', escapeCmdArg(target)];
641
+ }
642
+ return ['npx', '-y', 'mcp-remote', target];
643
+ }
644
+
645
+ function getArgBeforeSeparator(args, flagName) {
646
+ const dashIdx = args.indexOf('--');
647
+ if (dashIdx < 0) return null;
648
+ for (let i = 1; i < dashIdx; i++) {
649
+ if (args[i] === flagName && i + 1 < dashIdx) {
650
+ return String(args[i + 1]);
651
+ }
652
+ }
653
+ return null;
654
+ }
655
+
656
+ export function getWrappedTransport(entry) {
657
+ if (!isWrapped(entry)) return null;
658
+ const args = Array.isArray(entry.args) ? entry.args : [];
659
+ const transport = getArgBeforeSeparator(args, '--wrapped-transport');
660
+ return transport || 'stdio';
661
+ }
662
+
663
+ function encodeWrappedEntry(entry) {
664
+ return Buffer.from(JSON.stringify(entry), 'utf-8').toString('base64');
665
+ }
666
+
667
+ function decodeWrappedEntry(encoded) {
668
+ if (typeof encoded !== 'string' || !encoded) return null;
669
+ try {
670
+ const json = Buffer.from(encoded, 'base64').toString('utf-8');
671
+ const parsed = JSON.parse(json);
672
+ if (parsed && typeof parsed === 'object') return parsed;
673
+ } catch {
674
+ // ignore
675
+ }
676
+ return null;
677
+ }
678
+
679
+ export function isUrlBridgeAvailable() {
680
+ const locator = process.platform === 'win32' ? 'where' : 'which';
681
+ try {
682
+ const result = spawnSync(locator, ['npx'], {
683
+ stdio: 'ignore',
684
+ timeout: 5000,
685
+ });
686
+ return result.status === 0;
687
+ } catch {
688
+ return false;
689
+ }
690
+ }
691
+
692
+ export function commandExists(commandName) {
693
+ const locator = process.platform === 'win32' ? 'where' : 'which';
694
+ try {
695
+ const result = spawnSync(locator, [commandName], {
696
+ stdio: 'ignore',
697
+ timeout: 5000,
698
+ });
699
+ return result.status === 0;
700
+ } catch {
701
+ return false;
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Return a copy of process.env without keys that block nested Claude CLI calls.
707
+ * @returns {Record<string, string>}
708
+ */
709
+ export function cleanEnvForClaude() {
710
+ const blocked = new Set(['CLAUDECODE', 'CLAUDE_CODE']);
711
+ const env = {};
712
+ for (const [key, value] of Object.entries(process.env)) {
713
+ if (!blocked.has(key)) {
714
+ env[key] = value;
715
+ }
716
+ }
717
+ return env;
718
+ }
719
+
720
+ function runClaudeMcpCommand(args) {
721
+ const result = spawnSync('claude', ['mcp', ...args], {
722
+ encoding: 'utf-8',
723
+ stdio: ['ignore', 'pipe', 'pipe'],
724
+ timeout: 60000,
725
+ env: cleanEnvForClaude(),
726
+ });
727
+ if (result.error) {
728
+ throw new Error(`failed to run 'claude mcp ${args.join(' ')}': ${result.error.message}`);
729
+ }
730
+ if (result.status !== 0) {
731
+ const stderr = String(result.stderr || '').trim();
732
+ const stdout = String(result.stdout || '').trim();
733
+ const detail = stderr || stdout || `exit code ${result.status}`;
734
+ throw new Error(`'claude mcp ${args.join(' ')}' failed: ${detail}`);
735
+ }
736
+ return String(result.stdout || '');
737
+ }
738
+
739
+ /**
740
+ * Parse server names from `claude mcp list` output.
741
+ *
742
+ * @param {string} output
743
+ * @returns {string[]}
744
+ */
745
+ export function parseClaudeMcpListNames(output) {
746
+ const names = [];
747
+ const seen = new Set();
748
+ for (const rawLine of String(output || '').split(/\r?\n/)) {
749
+ const line = rawLine.trimEnd();
750
+ const match = line.match(/^([^:\r\n]+):\s+/);
751
+ if (!match) continue;
752
+ const name = match[1].trim();
753
+ if (!name || seen.has(name)) continue;
754
+ if (!isSafePropertyName(name)) continue;
755
+ seen.add(name);
756
+ names.push(name);
757
+ }
758
+ return names;
759
+ }
760
+
761
+ /**
762
+ * Convert a cloud connector display name to a safe property name.
763
+ *
764
+ * "claude.ai Canva" -> "canva"
765
+ * "claude.ai Some Service" -> "some-service"
766
+ *
767
+ * @param {string} displayName
768
+ * @returns {string}
769
+ */
770
+ function sanitizeCloudConnectorName(displayName) {
771
+ let cleaned = String(displayName || '').trim().replace(/^claude\.ai\s+/i, '');
772
+ cleaned = cleaned.toLowerCase().trim();
773
+ cleaned = cleaned.replace(/\s+/g, '-');
774
+ cleaned = cleaned.replace(/[^a-z0-9-]/g, '');
775
+ cleaned = cleaned.replace(/^-+|-+$/g, '');
776
+ return cleaned;
777
+ }
778
+
779
+ const CLOUD_CONNECTOR_LINE_PATTERN = /^(claude\.ai\s+[^:]+):\s+(https?:\/\/\S+)\s+-\s+/i;
780
+
781
+ /**
782
+ * Parse cloud connector entries directly from `claude mcp list` output.
783
+ *
784
+ * Cloud connectors have names like "claude.ai Canva" which fail
785
+ * isSafePropertyName (spaces). This parser extracts them directly
786
+ * from the list output, which already contains the URL.
787
+ *
788
+ * @param {string} output
789
+ * @returns {Array<{displayName: string, safeName: string, url: string, scope: string, transport: string}>}
790
+ */
791
+ export function parseClaudeMcpListCloudConnectors(output) {
792
+ const results = [];
793
+ const seen = new Set();
794
+ for (const rawLine of String(output || '').split(/\r?\n/)) {
795
+ const line = rawLine.trimEnd();
796
+ const match = line.match(CLOUD_CONNECTOR_LINE_PATTERN);
797
+ if (!match) continue;
798
+ const displayName = match[1].trim();
799
+ const url = match[2].trim();
800
+ const safeName = sanitizeCloudConnectorName(displayName);
801
+ if (!safeName || seen.has(safeName)) continue;
802
+ if (!isSafePropertyName(safeName)) continue;
803
+ seen.add(safeName);
804
+ results.push({
805
+ displayName,
806
+ safeName,
807
+ url,
808
+ scope: 'cloud',
809
+ transport: 'sse',
810
+ });
811
+ }
812
+ return results;
813
+ }
814
+
815
+ /**
816
+ * Parse details from `claude mcp get <name>` output.
817
+ *
818
+ * @param {string} output
819
+ * @returns {{scope: string|null, type: string|null, url: string|null, command: string|null, args: string|null, headers: Record<string, string>}}
820
+ */
821
+ export function parseClaudeMcpGetDetails(output) {
822
+ const info = {
823
+ scope: null,
824
+ type: null,
825
+ url: null,
826
+ command: null,
827
+ args: null,
828
+ headers: {},
829
+ };
830
+
831
+ let inHeaders = false;
832
+ for (const rawLine of String(output || '').split(/\r?\n/)) {
833
+ const line = rawLine.trimEnd();
834
+ let match;
835
+
836
+ if ((match = line.match(/^\s{2}Scope:\s*(.+)$/))) {
837
+ info.scope = match[1].trim();
838
+ inHeaders = false;
839
+ continue;
840
+ }
841
+ if ((match = line.match(/^\s{2}Type:\s*(.+)$/))) {
842
+ info.type = match[1].trim().toLowerCase();
843
+ inHeaders = false;
844
+ continue;
845
+ }
846
+ if ((match = line.match(/^\s{2}URL:\s*(.+)$/))) {
847
+ info.url = match[1].trim();
848
+ inHeaders = false;
849
+ continue;
850
+ }
851
+ if ((match = line.match(/^\s{2}Command:\s*(.+)$/))) {
852
+ info.command = match[1].trim();
853
+ inHeaders = false;
854
+ continue;
855
+ }
856
+ if ((match = line.match(/^\s{2}Args:\s*(.*)$/))) {
857
+ info.args = match[1].trim();
858
+ inHeaders = false;
859
+ continue;
860
+ }
861
+ if (line.match(/^\s{2}Headers:\s*$/)) {
862
+ inHeaders = true;
863
+ continue;
864
+ }
865
+ if (!inHeaders) continue;
866
+
867
+ match = line.match(/^\s{4}([^:]+):\s*(.*)$/);
868
+ if (match) {
869
+ info.headers[match[1].trim()] = match[2].trim();
870
+ continue;
871
+ }
872
+ if (line.trim().length === 0) {
873
+ continue;
874
+ }
875
+ inHeaders = false;
876
+ }
877
+
878
+ return info;
879
+ }
880
+
881
+ export function isClaudeLocalScope(scopeLabel) {
882
+ return CLAUDE_LOCAL_SCOPE_PATTERN.test(String(scopeLabel || '').trim());
883
+ }
884
+
885
+ export function isClaudeCloudScope(scopeLabel) {
886
+ const normalized = String(scopeLabel || '').trim();
887
+ if (!normalized) return false;
888
+ if (isClaudeLocalScope(normalized)) return false;
889
+ return CLAUDE_CLOUD_SCOPE_PATTERN.test(normalized);
890
+ }
891
+
892
+ /**
893
+ * Detect whether an MCP server entry is already wrapped by ultra-lean-mcp-proxy.
894
+ *
895
+ * Structural detection checks:
896
+ * - args[0] === "proxy"
897
+ * - args contains "--runtime" followed by a value before "--"
898
+ * - args contains "--" separator
899
+ * - at least one arg after "--"
900
+ *
901
+ * @param {object} entry
902
+ * @returns {boolean}
903
+ */
904
+ export function isWrapped(entry) {
905
+ if (typeof entry !== 'object' || entry === null) return false;
906
+ const args = entry.args;
907
+ if (!Array.isArray(args) || args.length === 0) return false;
908
+
909
+ // Check: first arg is "proxy"
910
+ if (args[0] !== 'proxy') return false;
911
+
912
+ // Check: contains "--" separator
913
+ const dashIdx = args.indexOf('--');
914
+ if (dashIdx < 0) return false;
915
+
916
+ // Check: at least one arg after "--"
917
+ if (dashIdx >= args.length - 1) return false;
918
+
919
+ // Check: contains "--runtime" with a value before "--"
920
+ let runtimeValue = null;
921
+ for (let i = 1; i < dashIdx; i++) {
922
+ if (args[i] === '--runtime' && i + 1 < dashIdx) {
923
+ runtimeValue = args[i + 1];
924
+ break;
925
+ }
926
+ }
927
+ if (!runtimeValue || !['pip', 'npm'].includes(runtimeValue)) return false;
928
+ return true;
929
+ }
930
+
931
+ /**
932
+ * Extract the runtime value from a wrapped entry.
933
+ *
934
+ * @param {object} entry
935
+ * @returns {string|null}
936
+ */
937
+ export function getRuntime(entry) {
938
+ if (!isWrapped(entry)) return null;
939
+ const args = entry.args;
940
+ const dashIdx = args.indexOf('--');
941
+ for (let i = 1; i < dashIdx; i++) {
942
+ if (args[i] === '--runtime' && i + 1 < dashIdx) {
943
+ return args[i + 1];
944
+ }
945
+ }
946
+ return null;
947
+ }
948
+
949
+ /**
950
+ * Wrap a stdio MCP server entry to route through ultra-lean-mcp-proxy.
951
+ *
952
+ * @param {object} entry Original server entry (command + args)
953
+ * @param {string} proxyPath Absolute path to the proxy binary
954
+ * @param {string} runtime Runtime identifier (default: "npm")
955
+ * @returns {object} New entry with proxy wrapping
956
+ */
957
+ export function wrapEntry(entry, proxyPath, runtime = 'npm') {
958
+ const originalCommand = entry.command;
959
+ const originalArgs = Array.isArray(entry.args) ? [...entry.args] : [];
960
+
961
+ const newArgs = [
962
+ 'proxy',
963
+ '--runtime', runtime,
964
+ '--',
965
+ originalCommand,
966
+ ...originalArgs,
967
+ ];
968
+
969
+ const wrapped = { ...entry };
970
+ wrapped.command = proxyPath;
971
+ wrapped.args = newArgs;
972
+ return wrapped;
973
+ }
974
+
975
+ export function wrapUrlEntry(entry, proxyPath, runtime = 'npm') {
976
+ if (isWrapped(entry)) return entry;
977
+ if (!isUrlServer(entry)) return entry;
978
+
979
+ const original = JSON.parse(JSON.stringify(entry));
980
+ const encoded = encodeWrappedEntry(original);
981
+ const bridgeArgs = bridgeCommandForUrl(entry.url);
982
+
983
+ const wrapped = { ...entry };
984
+ wrapped.command = proxyPath;
985
+ wrapped.args = [
986
+ 'proxy',
987
+ '--runtime', runtime,
988
+ '--wrapped-transport', 'url',
989
+ '--wrapped-entry-b64', encoded,
990
+ '--',
991
+ ...bridgeArgs,
992
+ ];
993
+ delete wrapped.url;
994
+ delete wrapped.transport;
995
+ return wrapped;
996
+ }
997
+
998
+ /**
999
+ * Remove proxy wrapping from a server entry, restoring the original command.
1000
+ *
1001
+ * @param {object} entry Wrapped server entry
1002
+ * @returns {object} Unwrapped entry with original command restored
1003
+ */
1004
+ export function unwrapEntry(entry) {
1005
+ if (!isWrapped(entry)) return entry;
1006
+
1007
+ const args = entry.args;
1008
+ const encodedOriginal = getArgBeforeSeparator(args, '--wrapped-entry-b64');
1009
+ const wrappedTransport = getArgBeforeSeparator(args, '--wrapped-transport');
1010
+ if (wrappedTransport === 'url' && encodedOriginal) {
1011
+ const original = decodeWrappedEntry(encodedOriginal);
1012
+ if (original && typeof original === 'object') {
1013
+ return original;
1014
+ }
1015
+ }
1016
+
1017
+ const dashIdx = args.indexOf('--');
1018
+ const originalArgs = args.slice(dashIdx + 1);
1019
+
1020
+ if (originalArgs.length === 0) return entry;
1021
+
1022
+ const unwrapped = { ...entry };
1023
+ unwrapped.command = originalArgs[0];
1024
+ unwrapped.args = originalArgs.slice(1);
1025
+ return unwrapped;
1026
+ }
1027
+
1028
+ // ---------------------------------------------------------------------------
1029
+ // npx detection and global install
1030
+ // ---------------------------------------------------------------------------
1031
+
1032
+ /**
1033
+ * Detect whether the current process is running via npx (ephemeral cache).
1034
+ *
1035
+ * @returns {boolean}
1036
+ */
1037
+ export function isNpxContext() {
1038
+ const execPath = process.env.npm_execpath || '';
1039
+ if (execPath.includes('npx')) return true;
1040
+
1041
+ // Check if running from npm cache directory
1042
+ const dir = path.dirname(new URL(import.meta.url).pathname);
1043
+ if (dir.includes('_npx') || dir.includes('npm-cache')) return true;
1044
+
1045
+ // On Windows, the URL path may start with /C:/ - normalise
1046
+ const normalDir = process.platform === 'win32'
1047
+ ? dir.replace(/^\/([A-Za-z]:)/, '$1')
1048
+ : dir;
1049
+ if (normalDir.includes('_npx') || normalDir.includes('npm-cache')) return true;
1050
+
1051
+ return false;
1052
+ }
1053
+
1054
+ /**
1055
+ * Resolve the absolute path to the ultra-lean-mcp-proxy binary.
1056
+ *
1057
+ * When running via npx, this triggers a global install first.
1058
+ *
1059
+ * @returns {string}
1060
+ */
1061
+ export function resolveProxyPath() {
1062
+ const looksEphemeral = (candidate) => {
1063
+ const lower = String(candidate || '').toLowerCase();
1064
+ return (
1065
+ lower.includes('_npx')
1066
+ || lower.includes('npm-cache')
1067
+ || lower.includes(`${path.sep}temp${path.sep}`)
1068
+ || lower.includes(`${path.sep}tmp${path.sep}`)
1069
+ || lower.includes(os.tmpdir().toLowerCase())
1070
+ );
1071
+ };
1072
+
1073
+ const fromPrefix = (prefix) => {
1074
+ if (!prefix) return null;
1075
+ const candidates = process.platform === 'win32'
1076
+ ? [
1077
+ path.join(prefix, 'ultra-lean-mcp-proxy.cmd'),
1078
+ path.join(prefix, 'ultra-lean-mcp-proxy'),
1079
+ path.join(prefix, 'bin', 'ultra-lean-mcp-proxy.cmd'),
1080
+ ]
1081
+ : [
1082
+ path.join(prefix, 'bin', 'ultra-lean-mcp-proxy'),
1083
+ path.join(prefix, 'ultra-lean-mcp-proxy'),
1084
+ ];
1085
+ for (const candidate of candidates) {
1086
+ if (candidate && fs.existsSync(candidate) && !looksEphemeral(candidate)) {
1087
+ return candidate;
1088
+ }
1089
+ }
1090
+ return null;
1091
+ };
1092
+
1093
+ const fromPathLookup = () => {
1094
+ try {
1095
+ const cmd = process.platform === 'win32' ? 'where ultra-lean-mcp-proxy' : 'which ultra-lean-mcp-proxy';
1096
+ const result = execSync(cmd, {
1097
+ encoding: 'utf-8',
1098
+ stdio: ['ignore', 'pipe', 'pipe'],
1099
+ timeout: 5000,
1100
+ }).trim();
1101
+ const firstLine = result.split(/\r?\n/)[0].trim();
1102
+ if (firstLine && fs.existsSync(firstLine) && !looksEphemeral(firstLine)) {
1103
+ return firstLine;
1104
+ }
1105
+ } catch {
1106
+ // ignore
1107
+ }
1108
+ return null;
1109
+ };
1110
+
1111
+ const getGlobalPrefix = () => {
1112
+ const commands = ['npm prefix -g', 'npm config get prefix'];
1113
+ for (const command of commands) {
1114
+ try {
1115
+ const prefix = execSync(command, {
1116
+ encoding: 'utf-8',
1117
+ stdio: ['ignore', 'pipe', 'pipe'],
1118
+ timeout: 10000,
1119
+ }).trim();
1120
+ if (prefix) return prefix;
1121
+ } catch {
1122
+ // try next method
1123
+ }
1124
+ }
1125
+ return '';
1126
+ };
1127
+
1128
+ // If running via npx, install globally first
1129
+ if (isNpxContext()) {
1130
+ console.log('[installer] Detected npx context - installing globally for a stable path...');
1131
+ try {
1132
+ execSync('npm install -g ultra-lean-mcp-proxy', {
1133
+ stdio: ['ignore', 'pipe', 'pipe'],
1134
+ timeout: 60000,
1135
+ });
1136
+ } catch (err) {
1137
+ const stderr = err.stderr ? err.stderr.toString().trim() : '';
1138
+ throw new Error(`[installer] Failed to install globally: ${stderr || err.message}`);
1139
+ }
1140
+ }
1141
+
1142
+ const prefixCandidate = fromPrefix(getGlobalPrefix());
1143
+ if (prefixCandidate) return prefixCandidate;
1144
+
1145
+ const pathCandidate = fromPathLookup();
1146
+ if (pathCandidate) return pathCandidate;
1147
+
1148
+ // Fallback: use the current process entry point if it looks stable
1149
+ const selfPath = process.argv[1];
1150
+ if (selfPath && !looksEphemeral(selfPath) && fs.existsSync(selfPath)) {
1151
+ return selfPath;
1152
+ }
1153
+
1154
+ throw new Error(
1155
+ '[installer] Could not resolve a stable proxy binary path. '
1156
+ + 'Please install globally: npm install -g ultra-lean-mcp-proxy'
1157
+ );
1158
+ }
1159
+
1160
+ /**
1161
+ * Wrap cloud-scoped Claude MCP URL connectors by mirroring them locally.
1162
+ *
1163
+ * Reads `claude mcp list/get`, selects cloud scopes,
1164
+ * and writes wrapped mirror entries into `~/.claude.json`.
1165
+ *
1166
+ * @param {object} options
1167
+ * @param {boolean} options.dryRun Print what would change without writing
1168
+ * @param {"pip"|"npm"} options.runtime Runtime marker for wrapped entries
1169
+ * @param {string} options.suffix Suffix for mirror server names
1170
+ * @param {boolean} options.verbose Verbose output
1171
+ * @param {Function} options._commandExists Test injection
1172
+ * @param {Function} options._runClaudeMcpCommand Test injection
1173
+ * @param {Function} options._resolveProxyPath Test injection
1174
+ * @returns {Promise<object>}
1175
+ */
1176
+ export async function doWrapCloud(options = {}) {
1177
+ const {
1178
+ dryRun = false,
1179
+ runtime = 'npm',
1180
+ suffix = '-ulmp',
1181
+ verbose = false,
1182
+ _commandExists = commandExists,
1183
+ _runClaudeMcpCommand = runClaudeMcpCommand,
1184
+ _resolveProxyPath = resolveProxyPath,
1185
+ } = options;
1186
+
1187
+ if (typeof suffix !== 'string' || !suffix) {
1188
+ throw new Error('[wrap-cloud] --suffix must be a non-empty string');
1189
+ }
1190
+
1191
+ const selectedRuntime = runtime === 'pip' ? 'pip' : 'npm';
1192
+
1193
+ if (!_commandExists('claude')) {
1194
+ throw new Error('[wrap-cloud] `claude` CLI was not found on PATH. Install Claude Code CLI first.');
1195
+ }
1196
+
1197
+ const proxyPath = _resolveProxyPath();
1198
+ const listOutput = _runClaudeMcpCommand(['list']);
1199
+ const names = parseClaudeMcpListNames(listOutput);
1200
+ const cloudConnectors = parseClaudeMcpListCloudConnectors(listOutput);
1201
+
1202
+ if (names.length === 0 && cloudConnectors.length === 0) {
1203
+ if (listOutput.trim().length > 0) {
1204
+ console.warn('[wrap-cloud] Warning: `claude mcp list` produced output but no server names were parsed. The CLI output format may have changed.');
1205
+ }
1206
+ console.log('[wrap-cloud] No Claude MCP servers found.');
1207
+ return {
1208
+ inspected: 0,
1209
+ candidates: 0,
1210
+ written: 0,
1211
+ updated: 0,
1212
+ unchanged: 0,
1213
+ skipped: 0,
1214
+ configPath: null,
1215
+ };
1216
+ }
1217
+
1218
+ const candidates = [];
1219
+ let skipped = 0;
1220
+
1221
+ // --- Existing list-then-get flow for local/standard servers ---
1222
+ for (const name of names) {
1223
+ let details;
1224
+ try {
1225
+ details = parseClaudeMcpGetDetails(_runClaudeMcpCommand(['get', name]));
1226
+ } catch (err) {
1227
+ skipped++;
1228
+ if (verbose) {
1229
+ console.log(`[wrap-cloud] ${name}: skipped (failed to inspect: ${err.message || err})`);
1230
+ }
1231
+ continue;
1232
+ }
1233
+
1234
+ if (isClaudeLocalScope(details.scope)) {
1235
+ skipped++;
1236
+ if (verbose) {
1237
+ console.log(`[wrap-cloud] ${name}: skipped (scope is local/user/project)`);
1238
+ }
1239
+ continue;
1240
+ }
1241
+
1242
+ if (!isClaudeCloudScope(details.scope)) {
1243
+ skipped++;
1244
+ if (verbose) {
1245
+ console.log(`[wrap-cloud] ${name}: skipped (unknown scope: ${details.scope || 'empty'})`);
1246
+ }
1247
+ continue;
1248
+ }
1249
+
1250
+ const transport = String(details.type || '').toLowerCase();
1251
+ if (!['sse', 'http', 'streamable-http'].includes(transport)) {
1252
+ skipped++;
1253
+ if (verbose) {
1254
+ console.log(`[wrap-cloud] ${name}: skipped (cloud scope but non-URL transport: ${transport || 'unknown'})`);
1255
+ }
1256
+ continue;
1257
+ }
1258
+
1259
+ if (!details.url) {
1260
+ skipped++;
1261
+ if (verbose) {
1262
+ console.log(`[wrap-cloud] ${name}: skipped (cloud URL connector missing URL in CLI output)`);
1263
+ }
1264
+ continue;
1265
+ }
1266
+
1267
+ const targetName = `${name}${suffix}`;
1268
+ if (!isSafePropertyName(targetName)) {
1269
+ skipped++;
1270
+ if (verbose) {
1271
+ console.log(`[wrap-cloud] ${name}: skipped (target name "${targetName}" is not a safe property name)`);
1272
+ }
1273
+ continue;
1274
+ }
1275
+
1276
+ const sourceEntry = {
1277
+ url: details.url,
1278
+ transport,
1279
+ };
1280
+ if (details.headers && Object.keys(details.headers).length > 0) {
1281
+ sourceEntry.headers = details.headers;
1282
+ }
1283
+
1284
+ candidates.push({
1285
+ sourceName: name,
1286
+ targetName,
1287
+ scope: details.scope,
1288
+ wrappedEntry: wrapUrlEntry(sourceEntry, proxyPath, selectedRuntime),
1289
+ });
1290
+ }
1291
+
1292
+ // --- Cloud connector entries parsed directly from list output ---
1293
+ const candidateTargetNames = new Set(candidates.map((c) => c.targetName));
1294
+ for (const cc of cloudConnectors) {
1295
+ const targetName = `${cc.safeName}${suffix}`;
1296
+ if (!isSafePropertyName(targetName)) {
1297
+ skipped++;
1298
+ if (verbose) {
1299
+ console.log(`[wrap-cloud] ${cc.displayName}: skipped (target name "${targetName}" is not safe)`);
1300
+ }
1301
+ continue;
1302
+ }
1303
+ if (candidateTargetNames.has(targetName)) {
1304
+ if (verbose) {
1305
+ console.log(`[wrap-cloud] ${cc.displayName}: skipped (already collected via get)`);
1306
+ }
1307
+ continue;
1308
+ }
1309
+
1310
+ const sourceEntry = {
1311
+ url: cc.url,
1312
+ transport: cc.transport,
1313
+ };
1314
+ candidates.push({
1315
+ sourceName: cc.displayName,
1316
+ targetName,
1317
+ scope: cc.scope,
1318
+ wrappedEntry: wrapUrlEntry(sourceEntry, proxyPath, selectedRuntime),
1319
+ });
1320
+ candidateTargetNames.add(targetName);
1321
+ }
1322
+
1323
+ const inspectedCount = names.length + cloudConnectors.length;
1324
+
1325
+ if (candidates.length === 0) {
1326
+ console.log('[wrap-cloud] No cloud-scoped URL MCP servers found to wrap.');
1327
+ return {
1328
+ inspected: inspectedCount,
1329
+ candidates: 0,
1330
+ written: 0,
1331
+ updated: 0,
1332
+ unchanged: 0,
1333
+ skipped,
1334
+ configPath: null,
1335
+ };
1336
+ }
1337
+
1338
+ const locations = await getConfigLocations(true);
1339
+ const targetLoc = locations.find((loc) => normalizeClientName(loc.name) === 'claude-code-user') || {
1340
+ name: 'claude-code-user',
1341
+ path: path.join(os.homedir(), '.claude.json'),
1342
+ serverKey: 'mcpServers',
1343
+ };
1344
+ const configPath = targetLoc.path;
1345
+ const serverKey = targetLoc.serverKey || 'mcpServers';
1346
+
1347
+ let written = 0;
1348
+ let updated = 0;
1349
+ let unchanged = 0;
1350
+
1351
+ withLockedConfig(configPath, (config) => {
1352
+ if (!config[serverKey] || typeof config[serverKey] !== 'object') {
1353
+ config[serverKey] = {};
1354
+ }
1355
+ const servers = config[serverKey];
1356
+
1357
+ for (const candidate of candidates) {
1358
+ const existed = Object.prototype.hasOwnProperty.call(servers, candidate.targetName);
1359
+ const existing = existed ? servers[candidate.targetName] : undefined;
1360
+ if (existing && JSON.stringify(existing) === JSON.stringify(candidate.wrappedEntry)) {
1361
+ unchanged++;
1362
+ console.log(`[wrap-cloud] ${candidate.sourceName} -> ${candidate.targetName}: already up to date`);
1363
+ continue;
1364
+ }
1365
+
1366
+ if (dryRun) {
1367
+ const label = existed ? 'Would update' : 'Would create';
1368
+ console.log(`[wrap-cloud] ${candidate.sourceName} -> ${candidate.targetName}: ${label}`);
1369
+ } else {
1370
+ servers[candidate.targetName] = candidate.wrappedEntry;
1371
+ const label = existed ? 'Updated' : 'Created';
1372
+ console.log(`[wrap-cloud] ${candidate.sourceName} -> ${candidate.targetName}: ${label}`);
1373
+ }
1374
+
1375
+ if (existed) updated++;
1376
+ else written++;
1377
+ }
1378
+
1379
+ if (!dryRun && (written > 0 || updated > 0)) {
1380
+ console.log(`[wrap-cloud] Config saved: ${configPath}`);
1381
+ return config;
1382
+ }
1383
+ return null; // no write needed
1384
+ });
1385
+
1386
+ console.log('');
1387
+ console.log(
1388
+ `[wrap-cloud] Done. Inspected: ${inspectedCount}, Cloud URL candidates: ${candidates.length}, `
1389
+ + `Created: ${written}, Updated: ${updated}, Unchanged: ${unchanged}, Skipped: ${skipped}`
1390
+ );
1391
+ if (dryRun) {
1392
+ console.log('[wrap-cloud] (dry run - no files were modified)');
1393
+ }
1394
+
1395
+ return {
1396
+ inspected: inspectedCount,
1397
+ candidates: candidates.length,
1398
+ written,
1399
+ updated,
1400
+ unchanged,
1401
+ skipped,
1402
+ configPath,
1403
+ };
1404
+ }
1405
+
1406
+ // ---------------------------------------------------------------------------
1407
+ // Main operations
1408
+ // ---------------------------------------------------------------------------
1409
+
1410
+ /**
1411
+ * Install: wrap MCP server entries in discovered client configs.
1412
+ *
1413
+ * @param {object} options
1414
+ * @param {boolean} options.dryRun Print what would change without writing
1415
+ * @param {string|null} options.clientFilter Only process this client name
1416
+ * @param {string[]|string|null} options.skipNames Skip these server names
1417
+ * @param {boolean} options.offline Skip remote registry fetch
1418
+ * @param {boolean} options.wrapUrl Wrap URL/SSE/HTTP entries (default: true)
1419
+ * @param {"pip"|"npm"} options.runtime Runtime marker to write into wrappers
1420
+ * @param {boolean} options.verbose Verbose output
1421
+ */
1422
+ export async function doInstall(options = {}) {
1423
+ const {
1424
+ dryRun = false,
1425
+ clientFilter = null,
1426
+ skipNames = [],
1427
+ offline = false,
1428
+ wrapUrl = true,
1429
+ runtime = 'npm',
1430
+ verbose = false,
1431
+ } = options;
1432
+
1433
+ const selectedRuntime = runtime === 'pip' ? 'pip' : 'npm';
1434
+
1435
+ const proxyPath = resolveProxyPath();
1436
+ if (verbose) {
1437
+ console.log(`[installer] Proxy binary: ${proxyPath}`);
1438
+ }
1439
+
1440
+ const locations = await getConfigLocations(offline);
1441
+ const normalizedClientFilter = clientFilter ? normalizeClientName(clientFilter) : null;
1442
+ const skipSet = new Set(
1443
+ (Array.isArray(skipNames) ? skipNames : skipNames ? [skipNames] : [])
1444
+ .map((name) => String(name))
1445
+ );
1446
+ const canWrapUrl = wrapUrl ? isUrlBridgeAvailable() : false;
1447
+ if (wrapUrl && !canWrapUrl) {
1448
+ console.warn('[installer] URL wrapping is enabled but `npx` was not found; URL entries will be skipped.');
1449
+ }
1450
+ let wrapped = 0;
1451
+ let skipped = 0;
1452
+ let errors = 0;
1453
+
1454
+ for (const loc of locations) {
1455
+ // Client filter
1456
+ if (normalizedClientFilter && normalizeClientName(loc.name) !== normalizedClientFilter) {
1457
+ continue;
1458
+ }
1459
+
1460
+ const configPath = loc.path;
1461
+ const serverKey = loc.serverKey || 'mcpServers';
1462
+
1463
+ // Check if config file exists
1464
+ if (!fs.existsSync(configPath)) {
1465
+ if (verbose) {
1466
+ console.log(`[installer] ${loc.name}: config not found at ${configPath} -- skipping`);
1467
+ }
1468
+ continue;
1469
+ }
1470
+
1471
+ if (!acquireConfigLock(configPath)) {
1472
+ console.error(` Error: config is locked by another process`);
1473
+ errors++;
1474
+ continue;
1475
+ }
1476
+
1477
+ console.log(`[installer] ${loc.name}: ${configPath}`);
1478
+
1479
+ try {
1480
+ if (!fs.existsSync(configPath)) {
1481
+ if (verbose) {
1482
+ console.log(`[installer] ${loc.name}: config no longer exists -- skipping`);
1483
+ }
1484
+ continue;
1485
+ }
1486
+
1487
+ const config = readConfig(configPath);
1488
+ if (!config || typeof config !== 'object') {
1489
+ console.error(` Error: could not parse config`);
1490
+ errors++;
1491
+ continue;
1492
+ }
1493
+
1494
+ const servers = config[serverKey];
1495
+ if (!servers || typeof servers !== 'object') {
1496
+ if (verbose) {
1497
+ console.log(` No "${serverKey}" section found -- skipping`);
1498
+ }
1499
+ continue;
1500
+ }
1501
+
1502
+ let changed = false;
1503
+ for (const [serverName, entry] of Object.entries(servers)) {
1504
+ if (skipSet.has(serverName)) {
1505
+ if (verbose) {
1506
+ console.log(` ${serverName}: skip list -- skipping`);
1507
+ }
1508
+ skipped++;
1509
+ continue;
1510
+ }
1511
+
1512
+ const isStdio = isStdioServer(entry);
1513
+ const isUrl = isUrlServer(entry);
1514
+
1515
+ if (!isStdio && !isUrl) {
1516
+ if (verbose) {
1517
+ console.log(` ${serverName}: not a wrappable local server -- skipping`);
1518
+ }
1519
+ skipped++;
1520
+ continue;
1521
+ }
1522
+
1523
+ if (isWrapped(entry)) {
1524
+ if (verbose) {
1525
+ console.log(` ${serverName}: already wrapped -- skipping`);
1526
+ }
1527
+ skipped++;
1528
+ continue;
1529
+ }
1530
+
1531
+ if (isUrl && !wrapUrl) {
1532
+ if (verbose) {
1533
+ console.log(` ${serverName}: url wrapping disabled (--no-wrap-url) -- skipping`);
1534
+ }
1535
+ skipped++;
1536
+ continue;
1537
+ }
1538
+ if (isUrl && !canWrapUrl) {
1539
+ if (verbose) {
1540
+ console.log(` ${serverName}: bridge dependency unavailable (npx) -- skipping`);
1541
+ }
1542
+ skipped++;
1543
+ continue;
1544
+ }
1545
+
1546
+ const newEntry = isUrl
1547
+ ? wrapUrlEntry(entry, proxyPath, selectedRuntime)
1548
+ : wrapEntry(entry, proxyPath, selectedRuntime);
1549
+ if (dryRun) {
1550
+ const origin = isUrl ? 'url' : 'stdio';
1551
+ console.log(` ${serverName}: would wrap (${origin})`);
1552
+ console.log(` command: ${newEntry.command}`);
1553
+ console.log(` args: ${JSON.stringify(newEntry.args)}`);
1554
+ } else {
1555
+ servers[serverName] = newEntry;
1556
+ changed = true;
1557
+ const origin = isUrl ? 'url' : 'stdio';
1558
+ console.log(` ${serverName}: wrapped (${origin})`);
1559
+ }
1560
+ wrapped++;
1561
+ }
1562
+
1563
+ if (changed && !dryRun) {
1564
+ backupConfig(configPath);
1565
+ writeConfigAtomic(configPath, config);
1566
+ console.log(` Config saved (backup created)`);
1567
+ }
1568
+ } finally {
1569
+ releaseConfigLock(configPath);
1570
+ }
1571
+ }
1572
+
1573
+ console.log('');
1574
+ console.log(`Done. Wrapped: ${wrapped}, Skipped: ${skipped}, Errors: ${errors}`);
1575
+ if (dryRun) {
1576
+ console.log('(dry run - no files were modified)');
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Uninstall: unwrap MCP server entries in discovered client configs.
1582
+ *
1583
+ * @param {object} options
1584
+ * @param {boolean} options.dryRun Print what would change without writing
1585
+ * @param {string|null} options.clientFilter Only process this client name
1586
+ * @param {boolean} options.all Unwrap all runtimes
1587
+ * @param {string} options.runtime Runtime marker to unwrap (default: npm)
1588
+ * @param {boolean} options.verbose Verbose output
1589
+ */
1590
+ export async function doUninstall(options = {}) {
1591
+ const {
1592
+ dryRun = false,
1593
+ clientFilter = null,
1594
+ all = false,
1595
+ runtime = 'npm',
1596
+ verbose = false,
1597
+ } = options;
1598
+
1599
+ const locations = await getConfigLocations(true); // always offline for uninstall
1600
+ const normalizedClientFilter = clientFilter ? normalizeClientName(clientFilter) : null;
1601
+ let unwrapped = 0;
1602
+ let skipped = 0;
1603
+ let errors = 0;
1604
+
1605
+ for (const loc of locations) {
1606
+ if (normalizedClientFilter && normalizeClientName(loc.name) !== normalizedClientFilter) {
1607
+ continue;
1608
+ }
1609
+
1610
+ const configPath = loc.path;
1611
+ const serverKey = loc.serverKey || 'mcpServers';
1612
+
1613
+ if (!fs.existsSync(configPath)) {
1614
+ if (verbose) {
1615
+ console.log(`[installer] ${loc.name}: config not found at ${configPath} -- skipping`);
1616
+ }
1617
+ continue;
1618
+ }
1619
+
1620
+ if (!acquireConfigLock(configPath)) {
1621
+ console.error(` Error: config is locked by another process`);
1622
+ errors++;
1623
+ continue;
1624
+ }
1625
+
1626
+ console.log(`[installer] ${loc.name}: ${configPath}`);
1627
+
1628
+ try {
1629
+ if (!fs.existsSync(configPath)) {
1630
+ if (verbose) {
1631
+ console.log(`[installer] ${loc.name}: config no longer exists -- skipping`);
1632
+ }
1633
+ continue;
1634
+ }
1635
+
1636
+ const config = readConfig(configPath);
1637
+ if (!config || typeof config !== 'object') {
1638
+ console.error(` Error: could not parse config`);
1639
+ errors++;
1640
+ continue;
1641
+ }
1642
+
1643
+ const servers = config[serverKey];
1644
+ if (!servers || typeof servers !== 'object') {
1645
+ continue;
1646
+ }
1647
+
1648
+ let changed = false;
1649
+ for (const [serverName, entry] of Object.entries(servers)) {
1650
+ if (!isWrapped(entry)) {
1651
+ if (verbose) {
1652
+ console.log(` ${serverName}: not wrapped -- skipping`);
1653
+ }
1654
+ skipped++;
1655
+ continue;
1656
+ }
1657
+
1658
+ const entryRuntime = getRuntime(entry);
1659
+ if (!all && entryRuntime !== runtime) {
1660
+ if (verbose) {
1661
+ console.log(` ${serverName}: wrapped for ${entryRuntime}, expected ${runtime} -- skipping`);
1662
+ }
1663
+ skipped++;
1664
+ continue;
1665
+ }
1666
+
1667
+ const restored = unwrapEntry(entry);
1668
+ if (dryRun) {
1669
+ console.log(` ${serverName}: would unwrap`);
1670
+ console.log(` command: ${restored.command}`);
1671
+ console.log(` args: ${JSON.stringify(restored.args)}`);
1672
+ } else {
1673
+ servers[serverName] = restored;
1674
+ changed = true;
1675
+ console.log(` ${serverName}: unwrapped`);
1676
+ }
1677
+ unwrapped++;
1678
+ }
1679
+
1680
+ if (changed && !dryRun) {
1681
+ backupConfig(configPath);
1682
+ writeConfigAtomic(configPath, config);
1683
+ console.log(` Config saved (backup created)`);
1684
+ }
1685
+ } finally {
1686
+ releaseConfigLock(configPath);
1687
+ }
1688
+ }
1689
+
1690
+ console.log('');
1691
+ console.log(`Done. Unwrapped: ${unwrapped}, Skipped: ${skipped}, Errors: ${errors}`);
1692
+ if (dryRun) {
1693
+ console.log('(dry run - no files were modified)');
1694
+ }
1695
+ }
1696
+
1697
+ /**
1698
+ * Show the current install status for all discovered clients.
1699
+ */
1700
+ export async function showStatus() {
1701
+ const locations = await getConfigLocations(true);
1702
+
1703
+ console.log('Ultra Lean MCP Proxy - Status\n');
1704
+
1705
+ let found = false;
1706
+ for (const loc of locations) {
1707
+ const configPath = loc.path;
1708
+ const serverKey = loc.serverKey || 'mcpServers';
1709
+
1710
+ if (!fs.existsSync(configPath)) {
1711
+ console.log(`${loc.name}: not found`);
1712
+ console.log(` ${configPath}\n`);
1713
+ continue;
1714
+ }
1715
+
1716
+ found = true;
1717
+ const config = readConfig(configPath);
1718
+ if (!config || typeof config !== 'object') {
1719
+ console.log(`${loc.name}: error reading config`);
1720
+ console.log(` ${configPath}\n`);
1721
+ continue;
1722
+ }
1723
+
1724
+ const servers = config[serverKey];
1725
+ if (!servers || typeof servers !== 'object' || Object.keys(servers).length === 0) {
1726
+ console.log(`${loc.name}: no servers configured`);
1727
+ console.log(` ${configPath}\n`);
1728
+ continue;
1729
+ }
1730
+
1731
+ console.log(`${loc.name}: ${configPath}`);
1732
+ for (const [serverName, entry] of Object.entries(servers)) {
1733
+ const wrapped = isWrapped(entry);
1734
+ const stdio = isStdioServer(entry);
1735
+ const url = isUrlServer(entry);
1736
+ let status;
1737
+ if (wrapped) {
1738
+ const runtime = getRuntime(entry);
1739
+ const origin = getWrappedTransport(entry) || 'stdio';
1740
+ status = `wrapped (runtime: ${runtime || 'unknown'}, origin=${origin})`;
1741
+ } else if (stdio) {
1742
+ status = 'not wrapped (origin=stdio)';
1743
+ } else if (url) {
1744
+ status = 'remote (unwrapped)';
1745
+ } else {
1746
+ status = 'not wrappable (non-stdio)';
1747
+ }
1748
+ console.log(` ${serverName}: ${status}`);
1749
+ }
1750
+ console.log('');
1751
+ }
1752
+
1753
+ if (!found) {
1754
+ console.log('No MCP client configs found on this system.');
1755
+ }
1756
+ }