@alfe.ai/ctrader-mcp 0.3.0 → 0.3.2

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.
Files changed (2) hide show
  1. package/dist/server.js +66 -0
  2. package/package.json +2 -2
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.0",
3
+ "version": "0.3.2",
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",
@@ -21,7 +21,7 @@
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "protobufjs": "^8.7.0",
23
23
  "zod": "^4.0.5",
24
- "@alfe.ai/agent-api-client": "0.11.1",
24
+ "@alfe.ai/agent-api-client": "0.11.2",
25
25
  "@alfe.ai/config": "0.3.0"
26
26
  },
27
27
  "license": "UNLICENSED",