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/.claude-plugin/marketplace.json +23 -0
- package/LICENSE +21 -0
- package/PLUGIN_SPEC.md +388 -0
- package/README.md +306 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +215 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-detector.d.ts +53 -0
- package/dist/config-detector.d.ts.map +1 -0
- package/dist/config-detector.js +319 -0
- package/dist/config-detector.js.map +1 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +272 -0
- package/dist/index.js.map +1 -0
- package/dist/twin-manager.d.ts +40 -0
- package/dist/twin-manager.d.ts.map +1 -0
- package/dist/twin-manager.js +518 -0
- package/dist/twin-manager.js.map +1 -0
- package/examples/http-server.py +247 -0
- package/package.json +97 -0
- package/skills/twin.md +186 -0
- package/src/cli.ts +217 -0
- package/src/config-detector.ts +340 -0
- package/src/index.ts +309 -0
- package/src/twin-manager.ts +596 -0
- package/tsconfig.json +19 -0
|
@@ -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
|
+
}
|