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.
- package/bin/cli.mjs +311 -0
- package/package.json +23 -0
- package/src/compress.mjs +170 -0
- package/src/config.mjs +496 -0
- package/src/delta.mjs +188 -0
- package/src/installer.mjs +1756 -0
- package/src/proxy.mjs +1122 -0
- package/src/result-compression.mjs +332 -0
- package/src/state.mjs +293 -0
- package/src/tools-hash-sync.mjs +52 -0
- package/src/watcher.mjs +530 -0
|
@@ -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
|
+
}
|