mcp-twin 1.2.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/src/cli.ts ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Twin CLI - Universal zero-downtime MCP server management
4
+ *
5
+ * Works with ANY MCP client: Claude Desktop, VS Code, Cursor, Cline, etc.
6
+ *
7
+ * Usage:
8
+ * npx mcp-twin start my-server
9
+ * npx mcp-twin reload my-server
10
+ * npx mcp-twin swap my-server
11
+ * npx mcp-twin status
12
+ *
13
+ * Powered by Prax Chat - https://prax.chat
14
+ */
15
+
16
+ import { getTwinManager } from './twin-manager';
17
+ import { autoConfigureTwins } from './config-detector';
18
+
19
+ const PRAX_FOOTER = '\n─────────────────────────────────────────\n⚡ Powered by Prax Chat | prax.chat/mcp-twin';
20
+
21
+ const VERSION = '1.2.0';
22
+
23
+ async function main() {
24
+ const args = process.argv.slice(2);
25
+ const command = args[0]?.toLowerCase();
26
+ const serverName = args[1];
27
+
28
+ const manager = getTwinManager();
29
+
30
+ switch (command) {
31
+ case 'start': {
32
+ if (!serverName) {
33
+ const status = await manager.status();
34
+ console.log('Available servers:');
35
+ (status.available || []).forEach((s: string) => console.log(` - ${s}`));
36
+ console.log('\nUsage: mcp-twin start <server-name>');
37
+ break;
38
+ }
39
+ const result = await manager.startTwins(serverName);
40
+ if (result.ok) {
41
+ console.log(`✓ Started twins for ${serverName}`);
42
+ console.log(` Server A: port ${result.portA} (${result.statusA})`);
43
+ console.log(` Server B: port ${result.portB} (${result.statusB})`);
44
+ console.log(` Active: A`);
45
+ } else {
46
+ console.error(`✗ Failed: ${result.error}`);
47
+ process.exit(1);
48
+ }
49
+ console.log(PRAX_FOOTER);
50
+ break;
51
+ }
52
+
53
+ case 'stop': {
54
+ if (!serverName) {
55
+ console.log('Usage: mcp-twin stop <server-name>');
56
+ console.log(' mcp-twin stop --all');
57
+ break;
58
+ }
59
+ if (serverName === '--all') {
60
+ const status = await manager.status();
61
+ for (const name of Object.keys(status.twins || {})) {
62
+ await manager.stopTwins(name);
63
+ console.log(`✓ Stopped ${name}`);
64
+ }
65
+ } else {
66
+ const result = await manager.stopTwins(serverName);
67
+ if (result.ok) {
68
+ console.log(`✓ Stopped ${serverName} twins`);
69
+ } else {
70
+ console.error(`✗ Failed: ${result.error}`);
71
+ process.exit(1);
72
+ }
73
+ }
74
+ break;
75
+ }
76
+
77
+ case 'reload': {
78
+ if (!serverName) {
79
+ console.log('Usage: mcp-twin reload <server-name>');
80
+ break;
81
+ }
82
+ console.log(`Reloading ${serverName} standby...`);
83
+ const result = await manager.reloadStandby(serverName);
84
+ if (result.ok) {
85
+ console.log(`✓ Reloaded ${result.reloaded}`);
86
+ console.log(` Health: ${result.healthy ? 'passing ●' : 'FAILED ○'}`);
87
+ console.log(` Reload count: ${result.reloadCount}`);
88
+ if (result.healthy) {
89
+ console.log(`\nReady to swap: mcp-twin swap ${serverName}`);
90
+ }
91
+ } else {
92
+ console.error(`✗ Failed: ${result.error}`);
93
+ process.exit(1);
94
+ }
95
+ console.log(PRAX_FOOTER);
96
+ break;
97
+ }
98
+
99
+ case 'swap': {
100
+ if (!serverName) {
101
+ console.log('Usage: mcp-twin swap <server-name>');
102
+ break;
103
+ }
104
+ const result = await manager.swapActive(serverName);
105
+ if (result.ok) {
106
+ console.log(`✓ Swapped ${serverName}`);
107
+ console.log(` Previous: ${result.previousActive}`);
108
+ console.log(` Now active: ${result.newActive}`);
109
+ } else {
110
+ console.error(`✗ Failed: ${result.error}`);
111
+ if (result.hint) console.log(` Hint: ${result.hint}`);
112
+ process.exit(1);
113
+ }
114
+ console.log(PRAX_FOOTER);
115
+ break;
116
+ }
117
+
118
+ case 'status': {
119
+ const result = await manager.status(serverName);
120
+ if (serverName && result.ok) {
121
+ console.log(`${serverName} Twin Status`);
122
+ console.log('═'.repeat(40));
123
+ console.log(`\nServer A (port ${result.serverA.port})`);
124
+ console.log(` State: ${result.serverA.state}`);
125
+ console.log(` Health: ${result.serverA.healthy ? 'healthy ●' : 'unhealthy ○'}`);
126
+ console.log(` ${result.serverA.isActive ? '← ACTIVE' : ' standby'}`);
127
+ console.log(`\nServer B (port ${result.serverB.port})`);
128
+ console.log(` State: ${result.serverB.state}`);
129
+ console.log(` Health: ${result.serverB.healthy ? 'healthy ●' : 'unhealthy ○'}`);
130
+ console.log(` ${result.serverB.isActive ? '← ACTIVE' : ' standby'}`);
131
+ console.log(`\nReloads: ${result.reloadCount}`);
132
+ } else if (result.ok) {
133
+ console.log('MCP Twin Status');
134
+ console.log('═'.repeat(40));
135
+ const twins = result.twins || {};
136
+ if (Object.keys(twins).length === 0) {
137
+ console.log('\nNo twins running.');
138
+ console.log('\nAvailable servers:');
139
+ (result.available || []).forEach((s: string) => console.log(` - ${s}`));
140
+ } else {
141
+ for (const [name, info] of Object.entries(twins) as [string, any][]) {
142
+ const activePort = info.ports[info.active === 'a' ? 0 : 1];
143
+ console.log(`\n${name}`);
144
+ console.log(` Active: ${info.active.toUpperCase()} (port ${activePort}) ${info.healthy[info.active === 'a' ? 0 : 1] ? '●' : '○'}`);
145
+ console.log(` Reloads: ${info.reloadCount}`);
146
+ }
147
+ }
148
+ }
149
+ break;
150
+ }
151
+
152
+ case 'detect': {
153
+ console.log('Detecting MCP servers...');
154
+ const result = autoConfigureTwins();
155
+ if (result.detected === 0) {
156
+ console.log('No MCP servers found.');
157
+ console.log('\nSearched:');
158
+ console.log(' - Claude Desktop: ~/Library/Application Support/Claude/');
159
+ console.log(' - Claude Code: ~/.claude/');
160
+ console.log(' - VS Code: .vscode/mcp.json');
161
+ console.log(' - Cursor: ~/.cursor/mcp.json');
162
+ } else {
163
+ console.log(`✓ Detected ${result.detected} MCP servers:`);
164
+ result.servers.forEach((s: string) => console.log(` - ${s}`));
165
+ console.log(`\n${result.configured} configured for twins.`);
166
+ }
167
+ break;
168
+ }
169
+
170
+ case 'version':
171
+ case '-v':
172
+ case '--version': {
173
+ console.log(`MCP Twin v${VERSION}`);
174
+ console.log('Powered by Prax Chat - https://prax.chat');
175
+ break;
176
+ }
177
+
178
+ case 'help':
179
+ case '-h':
180
+ case '--help':
181
+ default: {
182
+ console.log(`
183
+ MCP Twin v${VERSION} - Zero-Downtime MCP Server Updates
184
+ ${'═'.repeat(50)}
185
+
186
+ Works with: Claude Desktop, VS Code, Cursor, Cline, Windsurf
187
+
188
+ Commands:
189
+ mcp-twin start <server> Start twin servers
190
+ mcp-twin stop <server> Stop twin servers (--all for all)
191
+ mcp-twin reload <server> Reload standby with new code
192
+ mcp-twin swap <server> Switch traffic to standby
193
+ mcp-twin status [server] Show twin status
194
+ mcp-twin detect Auto-detect MCP servers
195
+ mcp-twin help Show this help
196
+
197
+ Workflow:
198
+ 1. mcp-twin start my-server → Start A (active) + B (standby)
199
+ 2. Edit your server code
200
+ 3. mcp-twin reload my-server → Update standby B
201
+ 4. mcp-twin swap my-server → Switch to B (zero downtime!)
202
+
203
+ Config: ~/.mcp-twin/config.json
204
+ Logs: ~/.mcp-twin/logs/
205
+ ${PRAX_FOOTER}
206
+
207
+ Pro features: prax.chat/mcp-twin/pro
208
+ `);
209
+ break;
210
+ }
211
+ }
212
+ }
213
+
214
+ main().catch(err => {
215
+ console.error('Error:', err.message);
216
+ process.exit(1);
217
+ });
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Config Detector - Auto-detect MCP servers from multiple MCP clients
3
+ *
4
+ * Supports:
5
+ * - Claude Desktop
6
+ * - Claude Code
7
+ * - VS Code (GitHub Copilot)
8
+ * - Cursor
9
+ * - Cline
10
+ * - Windsurf
11
+ *
12
+ * Powered by Prax Chat - https://prax.chat
13
+ */
14
+
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+
19
+ interface MCPServerEntry {
20
+ command: string;
21
+ args?: string[];
22
+ env?: Record<string, string>;
23
+ }
24
+
25
+ interface MCPConfig {
26
+ mcpServers?: Record<string, MCPServerEntry>;
27
+ servers?: Record<string, MCPServerEntry>; // VS Code/Cursor format
28
+ }
29
+
30
+ export interface DetectedServer {
31
+ name: string;
32
+ command: string;
33
+ args: string[];
34
+ scriptPath: string | null;
35
+ language: 'python' | 'node' | 'unknown';
36
+ source: string; // Which client config it came from
37
+ }
38
+
39
+ /**
40
+ * Get all possible MCP config paths for different clients
41
+ */
42
+ function getAllConfigPaths(): { client: string; path: string }[] {
43
+ const platform = os.platform();
44
+ const home = os.homedir();
45
+ const cwd = process.cwd();
46
+
47
+ const paths: { client: string; path: string }[] = [];
48
+
49
+ // Claude Desktop
50
+ if (platform === 'darwin') {
51
+ paths.push({
52
+ client: 'Claude Desktop',
53
+ path: path.join(home, 'Library/Application Support/Claude/claude_desktop_config.json')
54
+ });
55
+ } else if (platform === 'win32') {
56
+ paths.push({
57
+ client: 'Claude Desktop',
58
+ path: path.join(home, 'AppData/Roaming/Claude/claude_desktop_config.json')
59
+ });
60
+ } else {
61
+ paths.push({
62
+ client: 'Claude Desktop',
63
+ path: path.join(home, '.config/claude/claude_desktop_config.json')
64
+ });
65
+ }
66
+
67
+ // Claude Code
68
+ paths.push({
69
+ client: 'Claude Code',
70
+ path: path.join(home, '.claude/claude_desktop_config.json')
71
+ });
72
+
73
+ // VS Code - workspace
74
+ paths.push({
75
+ client: 'VS Code (workspace)',
76
+ path: path.join(cwd, '.vscode/mcp.json')
77
+ });
78
+
79
+ // VS Code - user settings (macOS)
80
+ if (platform === 'darwin') {
81
+ paths.push({
82
+ client: 'VS Code (user)',
83
+ path: path.join(home, 'Library/Application Support/Code/User/settings.json')
84
+ });
85
+ }
86
+
87
+ // Cursor - workspace
88
+ paths.push({
89
+ client: 'Cursor (workspace)',
90
+ path: path.join(cwd, '.cursor/mcp.json')
91
+ });
92
+
93
+ // Cursor - global
94
+ paths.push({
95
+ client: 'Cursor (global)',
96
+ path: path.join(home, '.cursor/mcp.json')
97
+ });
98
+
99
+ // Windsurf
100
+ paths.push({
101
+ client: 'Windsurf',
102
+ path: path.join(home, '.windsurf/mcp.json')
103
+ });
104
+
105
+ // Cline (VS Code extension, uses VS Code settings)
106
+ // Already covered by VS Code paths
107
+
108
+ return paths;
109
+ }
110
+
111
+ /**
112
+ * Get path to Claude desktop config (legacy, for backwards compatibility)
113
+ */
114
+ function getClaudeConfigPath(): string {
115
+ const platform = os.platform();
116
+
117
+ if (platform === 'darwin') {
118
+ return path.join(os.homedir(), 'Library/Application Support/Claude/claude_desktop_config.json');
119
+ } else if (platform === 'win32') {
120
+ return path.join(os.homedir(), 'AppData/Roaming/Claude/claude_desktop_config.json');
121
+ } else {
122
+ // Linux
123
+ return path.join(os.homedir(), '.config/claude/claude_desktop_config.json');
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Extract script path from command args
129
+ */
130
+ function extractScriptPath(command: string, args: string[]): string | null {
131
+ // Common patterns:
132
+ // python3 /path/to/server.py
133
+ // node /path/to/server.js
134
+ // npx ts-node /path/to/server.ts
135
+
136
+ for (const arg of args) {
137
+ // Skip flags
138
+ if (arg.startsWith('-')) continue;
139
+
140
+ // Check if it's a file path
141
+ const expanded = arg.replace('~', os.homedir());
142
+ if (fs.existsSync(expanded)) {
143
+ return expanded;
144
+ }
145
+
146
+ // Check common extensions
147
+ if (arg.endsWith('.py') || arg.endsWith('.js') || arg.endsWith('.ts')) {
148
+ return arg.replace('~', os.homedir());
149
+ }
150
+ }
151
+
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Detect language from command
157
+ */
158
+ function detectLanguage(command: string): 'python' | 'node' | 'unknown' {
159
+ const cmd = command.toLowerCase();
160
+
161
+ if (cmd.includes('python') || cmd.includes('python3')) {
162
+ return 'python';
163
+ }
164
+ if (cmd.includes('node') || cmd.includes('npx') || cmd.includes('tsx')) {
165
+ return 'node';
166
+ }
167
+
168
+ return 'unknown';
169
+ }
170
+
171
+ /**
172
+ * Assign deterministic ports based on server name
173
+ */
174
+ export function assignPorts(serverName: string): [number, number] {
175
+ // Simple hash function
176
+ let hash = 0;
177
+ for (let i = 0; i < serverName.length; i++) {
178
+ const char = serverName.charCodeAt(i);
179
+ hash = ((hash << 5) - hash) + char;
180
+ hash = hash & hash; // Convert to 32bit integer
181
+ }
182
+
183
+ // Map to port range 8100-8298 (99 server pairs)
184
+ const basePort = 8100 + (Math.abs(hash) % 100) * 2;
185
+ return [basePort, basePort + 1];
186
+ }
187
+
188
+ /**
189
+ * Detect MCP servers from all supported MCP clients
190
+ */
191
+ export function detectMCPServers(): DetectedServer[] {
192
+ const allPaths = getAllConfigPaths();
193
+ const servers: DetectedServer[] = [];
194
+ const seen = new Set<string>(); // Dedupe by server name
195
+
196
+ for (const { client, path: configPath } of allPaths) {
197
+ if (!fs.existsSync(configPath)) {
198
+ continue;
199
+ }
200
+
201
+ try {
202
+ const config: MCPConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
203
+
204
+ // Support both mcpServers (Claude) and servers (VS Code/Cursor) formats
205
+ const mcpServers = config.mcpServers || config.servers || {};
206
+
207
+ for (const [name, entry] of Object.entries(mcpServers)) {
208
+ // Skip if already seen (first config wins)
209
+ if (seen.has(name)) continue;
210
+ seen.add(name);
211
+
212
+ const args = entry.args || [];
213
+ const scriptPath = extractScriptPath(entry.command, args);
214
+ const language = detectLanguage(entry.command);
215
+
216
+ servers.push({
217
+ name,
218
+ command: entry.command,
219
+ args,
220
+ scriptPath,
221
+ language,
222
+ source: client
223
+ });
224
+ }
225
+ } catch (err) {
226
+ // Skip invalid configs silently
227
+ continue;
228
+ }
229
+ }
230
+
231
+ return servers;
232
+ }
233
+
234
+ /**
235
+ * Detect MCP servers from Claude config only (legacy)
236
+ */
237
+ export function detectMCPServersFromClaude(): DetectedServer[] {
238
+ const configPath = getClaudeConfigPath();
239
+
240
+ if (!fs.existsSync(configPath)) {
241
+ return [];
242
+ }
243
+
244
+ try {
245
+ const config: MCPConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
246
+ const servers: DetectedServer[] = [];
247
+
248
+ if (!config.mcpServers) {
249
+ return [];
250
+ }
251
+
252
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
253
+ const args = entry.args || [];
254
+ const scriptPath = extractScriptPath(entry.command, args);
255
+ const language = detectLanguage(entry.command);
256
+
257
+ servers.push({
258
+ name,
259
+ command: entry.command,
260
+ args,
261
+ scriptPath,
262
+ language,
263
+ source: 'Claude Desktop'
264
+ });
265
+ }
266
+
267
+ return servers;
268
+ } catch (err) {
269
+ return [];
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Generate twin config from detected servers
275
+ */
276
+ export function generateTwinConfig(servers: DetectedServer[]): Record<string, any> {
277
+ const config: Record<string, any> = {};
278
+
279
+ for (const server of servers) {
280
+ if (!server.scriptPath) {
281
+ console.log(`[ConfigDetector] Skipping ${server.name}: no script path found`);
282
+ continue;
283
+ }
284
+
285
+ const [portA, portB] = assignPorts(server.name);
286
+
287
+ config[server.name] = {
288
+ script: server.scriptPath,
289
+ ports: [portA, portB],
290
+ healthEndpoint: '/health',
291
+ startupTimeout: 10,
292
+ python: server.language === 'python' ? server.command : 'python3'
293
+ };
294
+ }
295
+
296
+ return config;
297
+ }
298
+
299
+ /**
300
+ * Auto-detect and update twin config
301
+ */
302
+ export function autoConfigureTwins(): { detected: number; configured: number; servers: string[] } {
303
+ const detected = detectMCPServers();
304
+ const twinConfig = generateTwinConfig(detected);
305
+
306
+ return {
307
+ detected: detected.length,
308
+ configured: Object.keys(twinConfig).length,
309
+ servers: Object.keys(twinConfig)
310
+ };
311
+ }
312
+
313
+ // CLI test
314
+ if (require.main === module) {
315
+ console.log('Detecting MCP servers...\n');
316
+
317
+ const servers = detectMCPServers();
318
+
319
+ if (servers.length === 0) {
320
+ console.log('No MCP servers found in Claude config.');
321
+ } else {
322
+ console.log(`Found ${servers.length} servers:\n`);
323
+
324
+ for (const server of servers) {
325
+ console.log(` ${server.name}`);
326
+ console.log(` Command: ${server.command}`);
327
+ console.log(` Script: ${server.scriptPath || '(not found)'}`);
328
+ console.log(` Language: ${server.language}`);
329
+ console.log(` Ports: ${assignPorts(server.name).join(', ')}`);
330
+ console.log();
331
+ }
332
+ }
333
+ }
334
+
335
+ export default {
336
+ detectMCPServers,
337
+ generateTwinConfig,
338
+ autoConfigureTwins,
339
+ assignPorts
340
+ };