@alfe.ai/ctrader-mcp 0.3.0 → 0.3.1
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/dist/server.js +66 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -494,6 +494,18 @@ const HEARTBEAT_INTERVAL_MS = 1e4;
|
|
|
494
494
|
const REQUEST_TIMEOUT_MS = 2e4;
|
|
495
495
|
const RECONNECT_BASE_MS = 1e3;
|
|
496
496
|
const RECONNECT_MAX_MS = 3e4;
|
|
497
|
+
/**
|
|
498
|
+
* Read-idle watchdog: cTrader echoes our 10s heartbeats and pushes its own
|
|
499
|
+
* traffic, so a *healthy* socket is never silent for long. If NO inbound byte
|
|
500
|
+
* arrives for this long we treat the socket as silently half-dead (a TCP
|
|
501
|
+
* half-open with no FIN — the OS never fires `close`, so `handleDrop` never
|
|
502
|
+
* runs and every request would otherwise time out at 20s indefinitely) and
|
|
503
|
+
* force a reconnect. Set to 3× the heartbeat interval so a single dropped
|
|
504
|
+
* heartbeat echo doesn't false-positive.
|
|
505
|
+
*/
|
|
506
|
+
const READ_IDLE_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS;
|
|
507
|
+
/** How often the watchdog checks the read-idle clock. */
|
|
508
|
+
const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
|
|
497
509
|
function log$1(msg) {
|
|
498
510
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
499
511
|
}
|
|
@@ -528,7 +540,12 @@ var HostSocket = class {
|
|
|
528
540
|
parser = new FrameParser();
|
|
529
541
|
pending = /* @__PURE__ */ new Map();
|
|
530
542
|
heartbeatTimer = null;
|
|
543
|
+
watchdogTimer = null;
|
|
544
|
+
/** Timestamp (ms) of the last inbound byte — drives the read-idle watchdog. */
|
|
545
|
+
lastInboundAt = 0;
|
|
531
546
|
reconnectAttempts = 0;
|
|
547
|
+
/** True from the moment a drop is handled until the reconnect succeeds. */
|
|
548
|
+
reconnectScheduled = false;
|
|
532
549
|
closing = false;
|
|
533
550
|
connectPromise = null;
|
|
534
551
|
/** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
|
|
@@ -551,9 +568,11 @@ var HostSocket = class {
|
|
|
551
568
|
}
|
|
552
569
|
async doStart() {
|
|
553
570
|
this.conn = await this.connectFn(this.host, CTRADER_PORT);
|
|
571
|
+
this.lastInboundAt = Date.now();
|
|
554
572
|
this.wireConnection(this.conn);
|
|
555
573
|
await this.appAuth();
|
|
556
574
|
this.startHeartbeat();
|
|
575
|
+
this.startWatchdog();
|
|
557
576
|
log$1(`Connected + app-authenticated to ${this.host}`);
|
|
558
577
|
}
|
|
559
578
|
/**
|
|
@@ -593,6 +612,7 @@ var HostSocket = class {
|
|
|
593
612
|
}
|
|
594
613
|
wireConnection(conn) {
|
|
595
614
|
conn.on("data", (chunk) => {
|
|
615
|
+
this.lastInboundAt = Date.now();
|
|
596
616
|
for (const frame of this.parser.push(chunk)) this.dispatch(frame);
|
|
597
617
|
});
|
|
598
618
|
conn.on("error", (err) => {
|
|
@@ -627,9 +647,51 @@ var HostSocket = class {
|
|
|
627
647
|
this.heartbeatTimer = null;
|
|
628
648
|
}
|
|
629
649
|
}
|
|
650
|
+
/**
|
|
651
|
+
* Start the read-idle watchdog. On a *silent* socket (no inbound byte for
|
|
652
|
+
* `READ_IDLE_TIMEOUT_MS`) the OS may never fire `close` (a TCP half-open with
|
|
653
|
+
* no FIN), so `handleDrop` never runs and every request just times out at 20s
|
|
654
|
+
* forever. The watchdog detects that silence and forces a teardown +
|
|
655
|
+
* reconnect, converting a wedged socket into a fast recovery.
|
|
656
|
+
*/
|
|
657
|
+
startWatchdog() {
|
|
658
|
+
this.stopWatchdog();
|
|
659
|
+
this.watchdogTimer = setInterval(() => {
|
|
660
|
+
if (!this.conn || this.closing) return;
|
|
661
|
+
const idleFor = Date.now() - this.lastInboundAt;
|
|
662
|
+
if (idleFor >= READ_IDLE_TIMEOUT_MS) {
|
|
663
|
+
log$1(`No inbound data from ${this.host} for ${String(idleFor)}ms — forcing reconnect`);
|
|
664
|
+
this.forceReconnect();
|
|
665
|
+
}
|
|
666
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
667
|
+
this.watchdogTimer.unref();
|
|
668
|
+
}
|
|
669
|
+
stopWatchdog() {
|
|
670
|
+
if (this.watchdogTimer) {
|
|
671
|
+
clearInterval(this.watchdogTimer);
|
|
672
|
+
this.watchdogTimer = null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Tear down a silently-wedged socket and reconnect. Destroying the socket
|
|
677
|
+
* normally fires `close`, but we don't rely on it — we drive `handleDrop`
|
|
678
|
+
* directly (which fails in-flight waiters and schedules the reconnect) and
|
|
679
|
+
* suppress the redundant `close` callback via the same `closing`-free path.
|
|
680
|
+
*/
|
|
681
|
+
forceReconnect() {
|
|
682
|
+
const dead = this.conn;
|
|
683
|
+
this.conn = null;
|
|
684
|
+
try {
|
|
685
|
+
dead?.destroy();
|
|
686
|
+
} catch {}
|
|
687
|
+
this.handleDrop();
|
|
688
|
+
}
|
|
630
689
|
handleDrop() {
|
|
690
|
+
if (!this.conn && this.heartbeatTimer === null && this.reconnectScheduled) return;
|
|
631
691
|
log$1(`Socket dropped (${this.host}) — attempting reconnect`);
|
|
632
692
|
this.stopHeartbeat();
|
|
693
|
+
this.stopWatchdog();
|
|
694
|
+
this.reconnectScheduled = true;
|
|
633
695
|
this.conn = null;
|
|
634
696
|
this.parser = new FrameParser();
|
|
635
697
|
this.authedAccounts.clear();
|
|
@@ -648,10 +710,13 @@ var HostSocket = class {
|
|
|
648
710
|
if (this.closing) return;
|
|
649
711
|
try {
|
|
650
712
|
this.conn = await this.connectFn(this.host, CTRADER_PORT);
|
|
713
|
+
this.lastInboundAt = Date.now();
|
|
651
714
|
this.wireConnection(this.conn);
|
|
652
715
|
await this.appAuth();
|
|
653
716
|
this.startHeartbeat();
|
|
717
|
+
this.startWatchdog();
|
|
654
718
|
this.reconnectAttempts = 0;
|
|
719
|
+
this.reconnectScheduled = false;
|
|
655
720
|
log$1(`Reconnected + re-app-authenticated (${this.host})`);
|
|
656
721
|
} catch (err) {
|
|
657
722
|
log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -738,6 +803,7 @@ var HostSocket = class {
|
|
|
738
803
|
close() {
|
|
739
804
|
this.closing = true;
|
|
740
805
|
this.stopHeartbeat();
|
|
806
|
+
this.stopWatchdog();
|
|
741
807
|
for (const [id, req] of this.pending) {
|
|
742
808
|
clearTimeout(req.timer);
|
|
743
809
|
req.reject(new CTraderError("CLIENT_CLOSED", "Client is shutting down"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/ctrader-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "cTrader MCP server — full trading (market/limit/stop/stop-limit orders, trailing stops), Level 2 depth, live quotes, trade history, PnL & margin over the cTrader Open API (protobuf/TLS)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/server.js",
|