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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * MCP Twin Manager - TypeScript Port
3
+ * Zero-downtime MCP server updates via twin/hot-swap architecture
4
+ */
5
+
6
+ import { spawn, ChildProcess } from 'child_process';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as net from 'net';
10
+ import * as os from 'os';
11
+
12
+ // Types
13
+ type ServerSlot = 'a' | 'b';
14
+
15
+ enum ServerState {
16
+ STOPPED = 'stopped',
17
+ STARTING = 'starting',
18
+ RUNNING = 'running',
19
+ FAILED = 'failed',
20
+ RELOADING = 'reloading'
21
+ }
22
+
23
+ interface TwinServer {
24
+ name: string;
25
+ scriptPath: string;
26
+ portA: number;
27
+ portB: number;
28
+ active: ServerSlot;
29
+ stateA: ServerState;
30
+ stateB: ServerState;
31
+ pidA: number | null;
32
+ pidB: number | null;
33
+ lastSwap: number | null;
34
+ reloadCount: number;
35
+ errorA: string | null;
36
+ errorB: string | null;
37
+ }
38
+
39
+ interface ServerConfig {
40
+ script: string;
41
+ ports: [number, number];
42
+ healthEndpoint: string;
43
+ startupTimeout: number;
44
+ python: string;
45
+ }
46
+
47
+ interface TwinConfig {
48
+ servers: Record<string, ServerConfig>;
49
+ settings: {
50
+ autoHealthCheck: boolean;
51
+ healthCheckInterval: number;
52
+ autoRestartOnFailure: boolean;
53
+ maxRestartAttempts: number;
54
+ startupWait: number;
55
+ shutdownTimeout: number;
56
+ };
57
+ }
58
+
59
+ interface TwinResult {
60
+ ok: boolean;
61
+ [key: string]: any;
62
+ }
63
+
64
+ // Paths
65
+ const CONFIG_DIR = path.join(os.homedir(), '.mcp-twin');
66
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
67
+ const STATE_FILE = path.join(CONFIG_DIR, 'state.json');
68
+ const LOG_DIR = path.join(CONFIG_DIR, 'logs');
69
+
70
+ /**
71
+ * MCPTwinManager - Manages twin MCP servers for zero-downtime updates
72
+ */
73
+ export class MCPTwinManager {
74
+ private twins: Map<string, TwinServer> = new Map();
75
+ private config: TwinConfig;
76
+ private processes: Map<string, ChildProcess> = new Map();
77
+
78
+ constructor() {
79
+ this.ensureDirs();
80
+ this.config = this.loadConfig();
81
+ this.restoreState();
82
+ }
83
+
84
+ private ensureDirs(): void {
85
+ [CONFIG_DIR, LOG_DIR].forEach(dir => {
86
+ if (!fs.existsSync(dir)) {
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ }
89
+ });
90
+ }
91
+
92
+ private loadConfig(): TwinConfig {
93
+ if (fs.existsSync(CONFIG_FILE)) {
94
+ try {
95
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
96
+ } catch {
97
+ return this.defaultConfig();
98
+ }
99
+ }
100
+ const config = this.defaultConfig();
101
+ this.saveConfig(config);
102
+ return config;
103
+ }
104
+
105
+ private defaultConfig(): TwinConfig {
106
+ return {
107
+ servers: {}, // Will be populated by auto-detect
108
+ settings: {
109
+ autoHealthCheck: true,
110
+ healthCheckInterval: 30,
111
+ autoRestartOnFailure: true,
112
+ maxRestartAttempts: 3,
113
+ startupWait: 2000,
114
+ shutdownTimeout: 5000
115
+ }
116
+ };
117
+ }
118
+
119
+ private saveConfig(config?: TwinConfig): void {
120
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config || this.config, null, 2));
121
+ }
122
+
123
+ private saveState(): void {
124
+ const state: Record<string, any> = {};
125
+ this.twins.forEach((twin, name) => {
126
+ state[name] = {
127
+ name: twin.name,
128
+ scriptPath: twin.scriptPath,
129
+ portA: twin.portA,
130
+ portB: twin.portB,
131
+ active: twin.active,
132
+ stateA: twin.stateA,
133
+ stateB: twin.stateB,
134
+ pidA: twin.pidA,
135
+ pidB: twin.pidB,
136
+ lastSwap: twin.lastSwap,
137
+ reloadCount: twin.reloadCount
138
+ };
139
+ });
140
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
141
+ }
142
+
143
+ private restoreState(): void {
144
+ if (!fs.existsSync(STATE_FILE)) return;
145
+
146
+ try {
147
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
148
+ Object.entries(state).forEach(([name, data]: [string, any]) => {
149
+ // Verify processes are still alive
150
+ const stateA = data.pidA && this.processAlive(data.pidA)
151
+ ? ServerState.RUNNING : ServerState.STOPPED;
152
+ const stateB = data.pidB && this.processAlive(data.pidB)
153
+ ? ServerState.RUNNING : ServerState.STOPPED;
154
+
155
+ this.twins.set(name, {
156
+ ...data,
157
+ stateA,
158
+ stateB,
159
+ pidA: stateA === ServerState.RUNNING ? data.pidA : null,
160
+ pidB: stateB === ServerState.RUNNING ? data.pidB : null,
161
+ errorA: null,
162
+ errorB: null
163
+ });
164
+ });
165
+ } catch (err) {
166
+ console.error('[MCPTwin] Failed to restore state:', err);
167
+ }
168
+ }
169
+
170
+ private processAlive(pid: number): boolean {
171
+ try {
172
+ process.kill(pid, 0);
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ private async healthCheck(port: number, pid?: number | null): Promise<boolean> {
180
+ // Check PID first (works for stdio MCP servers)
181
+ if (pid && this.processAlive(pid)) {
182
+ return true;
183
+ }
184
+
185
+ // TCP port check (works for HTTP servers)
186
+ return new Promise(resolve => {
187
+ const socket = new net.Socket();
188
+ socket.setTimeout(2000);
189
+
190
+ socket.on('connect', () => {
191
+ socket.destroy();
192
+ resolve(true);
193
+ });
194
+
195
+ socket.on('timeout', () => {
196
+ socket.destroy();
197
+ resolve(false);
198
+ });
199
+
200
+ socket.on('error', () => {
201
+ socket.destroy();
202
+ resolve(false);
203
+ });
204
+
205
+ socket.connect(port, 'localhost');
206
+ });
207
+ }
208
+
209
+ private startServer(script: string, port: number, serverName: string, slot: ServerSlot): number | null {
210
+ if (!fs.existsSync(script)) {
211
+ return null;
212
+ }
213
+
214
+ const logFile = path.join(LOG_DIR, `${serverName}_${slot}.log`);
215
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' });
216
+
217
+ const cfg = this.config.servers[serverName] || {};
218
+ const pythonCmd = cfg.python || 'python3';
219
+
220
+ try {
221
+ // All twin servers run in HTTP mode for hot-swap support
222
+ const proc = spawn(pythonCmd, [script, '--http', '--port', String(port)], {
223
+ cwd: path.dirname(script),
224
+ detached: true,
225
+ stdio: ['ignore', logStream, logStream]
226
+ });
227
+
228
+ proc.unref();
229
+
230
+ const key = `${serverName}_${slot}`;
231
+ this.processes.set(key, proc);
232
+
233
+ return proc.pid || null;
234
+ } catch (err) {
235
+ console.error(`[MCPTwin] Failed to start ${serverName}_${slot}:`, err);
236
+ return null;
237
+ }
238
+ }
239
+
240
+ private async stopServer(pid: number | null, timeout: number = 5000): Promise<boolean> {
241
+ if (!pid || !this.processAlive(pid)) {
242
+ return true;
243
+ }
244
+
245
+ try {
246
+ process.kill(pid, 'SIGTERM');
247
+
248
+ // Wait for graceful shutdown
249
+ const start = Date.now();
250
+ while (Date.now() - start < timeout) {
251
+ if (!this.processAlive(pid)) {
252
+ return true;
253
+ }
254
+ await this.sleep(100);
255
+ }
256
+
257
+ // Force kill
258
+ process.kill(pid, 'SIGKILL');
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ private sleep(ms: number): Promise<void> {
266
+ return new Promise(resolve => setTimeout(resolve, ms));
267
+ }
268
+
269
+ // === PUBLIC API ===
270
+
271
+ async startTwins(serverName: string): Promise<TwinResult> {
272
+ if (!this.config.servers[serverName]) {
273
+ return {
274
+ ok: false,
275
+ error: `Unknown server: ${serverName}. Available: ${Object.keys(this.config.servers).join(', ')}`
276
+ };
277
+ }
278
+
279
+ if (this.twins.has(serverName)) {
280
+ const twin = this.twins.get(serverName)!;
281
+ if (twin.stateA === ServerState.RUNNING || twin.stateB === ServerState.RUNNING) {
282
+ return {
283
+ ok: false,
284
+ error: `Twins already running for ${serverName}. Use 'twin stop ${serverName}' first.`
285
+ };
286
+ }
287
+ }
288
+
289
+ const cfg = this.config.servers[serverName];
290
+ const script = cfg.script.replace('~', os.homedir());
291
+
292
+ if (!fs.existsSync(script)) {
293
+ return { ok: false, error: `Script not found: ${script}` };
294
+ }
295
+
296
+ const [portA, portB] = cfg.ports;
297
+ const startupWait = this.config.settings.startupWait;
298
+
299
+ // Start server A
300
+ const pidA = this.startServer(script, portA, serverName, 'a');
301
+ await this.sleep(startupWait);
302
+ const healthA = await this.healthCheck(portA, pidA);
303
+
304
+ // Start server B
305
+ const pidB = this.startServer(script, portB, serverName, 'b');
306
+ await this.sleep(startupWait);
307
+ const healthB = await this.healthCheck(portB, pidB);
308
+
309
+ const twin: TwinServer = {
310
+ name: serverName,
311
+ scriptPath: script,
312
+ portA,
313
+ portB,
314
+ active: 'a',
315
+ stateA: healthA ? ServerState.RUNNING : ServerState.FAILED,
316
+ stateB: healthB ? ServerState.RUNNING : ServerState.FAILED,
317
+ pidA,
318
+ pidB,
319
+ lastSwap: null,
320
+ reloadCount: 0,
321
+ errorA: healthA ? null : 'Health check failed',
322
+ errorB: healthB ? null : 'Health check failed'
323
+ };
324
+
325
+ this.twins.set(serverName, twin);
326
+ this.saveState();
327
+
328
+ return {
329
+ ok: healthA || healthB,
330
+ server: serverName,
331
+ active: `a (port ${portA})`,
332
+ standby: `b (port ${portB})`,
333
+ statusA: twin.stateA,
334
+ statusB: twin.stateB,
335
+ pidA,
336
+ pidB,
337
+ hint: "Use 'twin status' to check, 'twin reload' after code changes"
338
+ };
339
+ }
340
+
341
+ async reloadStandby(serverName: string): Promise<TwinResult> {
342
+ if (!this.twins.has(serverName)) {
343
+ return {
344
+ ok: false,
345
+ error: `No twins running for ${serverName}. Use 'twin start ${serverName}' first.`
346
+ };
347
+ }
348
+
349
+ const twin = this.twins.get(serverName)!;
350
+ const standby: ServerSlot = twin.active === 'a' ? 'b' : 'a';
351
+ const standbyPort = standby === 'b' ? twin.portB : twin.portA;
352
+ const standbyPid = standby === 'b' ? twin.pidB : twin.pidA;
353
+
354
+ // Mark as reloading
355
+ if (standby === 'b') {
356
+ twin.stateB = ServerState.RELOADING;
357
+ } else {
358
+ twin.stateA = ServerState.RELOADING;
359
+ }
360
+ this.saveState();
361
+
362
+ // Stop standby
363
+ if (standbyPid) {
364
+ await this.stopServer(standbyPid, this.config.settings.shutdownTimeout);
365
+ }
366
+
367
+ // Start new instance
368
+ const newPid = this.startServer(twin.scriptPath, standbyPort, serverName, standby);
369
+ await this.sleep(this.config.settings.startupWait);
370
+ const healthy = await this.healthCheck(standbyPort, newPid);
371
+
372
+ // Update state
373
+ if (standby === 'b') {
374
+ twin.pidB = newPid;
375
+ twin.stateB = healthy ? ServerState.RUNNING : ServerState.FAILED;
376
+ twin.errorB = healthy ? null : 'Health check failed after reload';
377
+ } else {
378
+ twin.pidA = newPid;
379
+ twin.stateA = healthy ? ServerState.RUNNING : ServerState.FAILED;
380
+ twin.errorA = healthy ? null : 'Health check failed after reload';
381
+ }
382
+
383
+ twin.reloadCount++;
384
+ this.saveState();
385
+
386
+ return {
387
+ ok: healthy,
388
+ server: serverName,
389
+ reloaded: `standby (${standby}) on port ${standbyPort}`,
390
+ healthy,
391
+ newPid,
392
+ reloadCount: twin.reloadCount,
393
+ hint: healthy
394
+ ? `Standby ready. Use 'twin swap ${serverName}' to switch traffic.`
395
+ : `Health check failed. Check logs: ${path.join(LOG_DIR, `${serverName}_${standby}.log`)}`
396
+ };
397
+ }
398
+
399
+ async swapActive(serverName: string): Promise<TwinResult> {
400
+ if (!this.twins.has(serverName)) {
401
+ return { ok: false, error: `No twins running for ${serverName}` };
402
+ }
403
+
404
+ const twin = this.twins.get(serverName)!;
405
+ const newActive: ServerSlot = twin.active === 'a' ? 'b' : 'a';
406
+ const newPort = newActive === 'b' ? twin.portB : twin.portA;
407
+ const newState = newActive === 'b' ? twin.stateB : twin.stateA;
408
+
409
+ // Check standby is healthy
410
+ if (newState !== ServerState.RUNNING) {
411
+ return {
412
+ ok: false,
413
+ error: `Standby server (${newActive}) not healthy: ${newState}`,
414
+ hint: `Run 'twin reload ${serverName}' first to bring standby online`
415
+ };
416
+ }
417
+
418
+ const oldActive = twin.active;
419
+ const oldPort = oldActive === 'a' ? twin.portA : twin.portB;
420
+
421
+ // Swap
422
+ twin.active = newActive;
423
+ twin.lastSwap = Date.now();
424
+ this.saveState();
425
+
426
+ return {
427
+ ok: true,
428
+ server: serverName,
429
+ previousActive: `${oldActive} (port ${oldPort})`,
430
+ newActive: `${newActive} (port ${newPort})`,
431
+ swappedAt: twin.lastSwap,
432
+ hint: 'Traffic now routing to new active. Old server is now standby.'
433
+ };
434
+ }
435
+
436
+ async stopTwins(serverName: string): Promise<TwinResult> {
437
+ if (!this.twins.has(serverName)) {
438
+ return { ok: false, error: `No twins for ${serverName}` };
439
+ }
440
+
441
+ const twin = this.twins.get(serverName)!;
442
+ const timeout = this.config.settings.shutdownTimeout;
443
+
444
+ const stoppedA = await this.stopServer(twin.pidA, timeout);
445
+ const stoppedB = await this.stopServer(twin.pidB, timeout);
446
+
447
+ this.twins.delete(serverName);
448
+ this.saveState();
449
+
450
+ return {
451
+ ok: stoppedA && stoppedB,
452
+ server: serverName,
453
+ stopped: true,
454
+ stoppedA,
455
+ stoppedB
456
+ };
457
+ }
458
+
459
+ getActivePort(serverName: string): number | null {
460
+ const twin = this.twins.get(serverName);
461
+ if (!twin) return null;
462
+
463
+ if (twin.active === 'a' && twin.stateA === ServerState.RUNNING) {
464
+ return twin.portA;
465
+ } else if (twin.active === 'b' && twin.stateB === ServerState.RUNNING) {
466
+ return twin.portB;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ async status(serverName?: string): Promise<TwinResult> {
472
+ if (serverName) {
473
+ if (!this.twins.has(serverName)) {
474
+ if (this.config.servers[serverName]) {
475
+ return {
476
+ ok: true,
477
+ server: serverName,
478
+ status: 'not_started',
479
+ hint: `Use 'twin start ${serverName}' to start twins`
480
+ };
481
+ }
482
+ return { ok: false, error: `Unknown server: ${serverName}` };
483
+ }
484
+
485
+ const twin = this.twins.get(serverName)!;
486
+ const healthA = await this.healthCheck(twin.portA, twin.pidA);
487
+ const healthB = await this.healthCheck(twin.portB, twin.pidB);
488
+
489
+ return {
490
+ ok: true,
491
+ server: serverName,
492
+ active: twin.active,
493
+ serverA: {
494
+ port: twin.portA,
495
+ state: twin.stateA,
496
+ pid: twin.pidA,
497
+ healthy: healthA,
498
+ isActive: twin.active === 'a'
499
+ },
500
+ serverB: {
501
+ port: twin.portB,
502
+ state: twin.stateB,
503
+ pid: twin.pidB,
504
+ healthy: healthB,
505
+ isActive: twin.active === 'b'
506
+ },
507
+ reloadCount: twin.reloadCount,
508
+ lastSwap: twin.lastSwap
509
+ };
510
+ }
511
+
512
+ // All twins
513
+ const twinsStatus: Record<string, any> = {};
514
+
515
+ for (const [name, twin] of this.twins) {
516
+ const healthA = await this.healthCheck(twin.portA, twin.pidA);
517
+ const healthB = await this.healthCheck(twin.portB, twin.pidB);
518
+
519
+ twinsStatus[name] = {
520
+ active: twin.active,
521
+ ports: [twin.portA, twin.portB],
522
+ states: [twin.stateA, twin.stateB],
523
+ healthy: [healthA, healthB],
524
+ reloadCount: twin.reloadCount
525
+ };
526
+ }
527
+
528
+ return {
529
+ ok: true,
530
+ twins: twinsStatus,
531
+ available: Object.keys(this.config.servers),
532
+ configPath: CONFIG_FILE
533
+ };
534
+ }
535
+
536
+ getConfig(): TwinResult {
537
+ return {
538
+ ok: true,
539
+ configPath: CONFIG_FILE,
540
+ config: this.config
541
+ };
542
+ }
543
+
544
+ addServer(name: string, script: string, ports: [number, number]): TwinResult {
545
+ const scriptPath = script.replace('~', os.homedir());
546
+
547
+ if (!fs.existsSync(scriptPath)) {
548
+ return { ok: false, error: `Script not found: ${scriptPath}` };
549
+ }
550
+
551
+ this.config.servers[name] = {
552
+ script: scriptPath,
553
+ ports,
554
+ healthEndpoint: '/health',
555
+ startupTimeout: 10,
556
+ python: 'python3'
557
+ };
558
+ this.saveConfig();
559
+
560
+ return {
561
+ ok: true,
562
+ added: name,
563
+ config: this.config.servers[name]
564
+ };
565
+ }
566
+
567
+ removeServer(name: string): TwinResult {
568
+ if (!this.config.servers[name]) {
569
+ return { ok: false, error: `Server not in config: ${name}` };
570
+ }
571
+
572
+ if (this.twins.has(name)) {
573
+ return {
574
+ ok: false,
575
+ error: `Server has running twins. Stop them first with 'twin stop ${name}'`
576
+ };
577
+ }
578
+
579
+ delete this.config.servers[name];
580
+ this.saveConfig();
581
+
582
+ return { ok: true, removed: name };
583
+ }
584
+ }
585
+
586
+ // Singleton
587
+ let _twinManager: MCPTwinManager | null = null;
588
+
589
+ export function getTwinManager(): MCPTwinManager {
590
+ if (!_twinManager) {
591
+ _twinManager = new MCPTwinManager();
592
+ }
593
+ return _twinManager;
594
+ }
595
+
596
+ export default MCPTwinManager;
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }