@alfe.ai/ctrader-mcp 0.3.10 → 0.3.11

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 +92 -9
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -618,6 +618,33 @@ const RECONNECT_BASE_MS = 1e3;
618
618
  const RECONNECT_MAX_MS = 3e4;
619
619
  const MAX_PENDING_REQUESTS = 256;
620
620
  /**
621
+ * How long the TCP + TLS dial may take before we give up. Without this a
622
+ * blackholed SYN or a stalled TLS handshake never settles — `tls.connect`
623
+ * emits neither `secureConnect` nor `error` — so every caller of `start()`
624
+ * hung indefinitely. Observed in prod as MCP `-32001 Request timed out` on
625
+ * every ctrader tool while cron runs burned their whole budget inside one
626
+ * dial (Sentry LOCAL-MCP-K/-M/-N/-P).
627
+ */
628
+ const CONNECT_TIMEOUT_MS = 1e4;
629
+ /**
630
+ * How long `start()` lets a CALLER wait for a connection attempt (initial or
631
+ * reconnect) before failing that call while the attempt keeps going in the
632
+ * background. The reconnect loop retries forever by design; a tool call must
633
+ * not inherit that patience — the MCP client aborts at 60s (`-32001`) and
634
+ * cron jobs abort mid-call, both of which surface worse errors later than an
635
+ * honest, retryable `CONNECTION_UNAVAILABLE` now.
636
+ *
637
+ * Budget (connect/auth/tool path): 15s here + 20s account-auth request + 20s
638
+ * tool request = 55s worst case, inside the MCP client's 60s default request
639
+ * timeout. The rare `CH_ACCESS_TOKEN_INVALID` refresh+retry path has its own
640
+ * larger budget (refresh POST ≤20s, then a second auth+request pass) that can
641
+ * exceed the 60s window under compounded latency — accepted, because it fires
642
+ * only when the ~30-day grant token expires AND the refresh hop is slow, and
643
+ * it self-heals: the refresh rewrites the registry token even if THIS call is
644
+ * aborted, so the next call skips the refresh and fits the normal budget.
645
+ */
646
+ const START_WAIT_TIMEOUT_MS = 15e3;
647
+ /**
621
648
  * Read-idle watchdog: cTrader echoes our heartbeats and pushes its own traffic,
622
649
  * so a *healthy* socket is never silent for long. If NO inbound byte arrives
623
650
  * for this long we treat the socket as silently half-dead (a TCP half-open with
@@ -646,16 +673,26 @@ function log$1(msg) {
646
673
  function accountIdLong(accountId) {
647
674
  return protobuf.util.LongBits.from(accountId).toLong(false);
648
675
  }
649
- /** Production TLS transport. */
676
+ /** Production TLS transport. The dial is bounded by `CONNECT_TIMEOUT_MS`. */
650
677
  const tlsConnect = (host, port) => new Promise((resolve, reject) => {
651
678
  const socket = tls.connect({
652
679
  host,
653
680
  port,
654
681
  servername: host
655
- }, () => {
682
+ });
683
+ const dialTimer = setTimeout(() => {
684
+ socket.destroy();
685
+ reject(new CTraderError("CONNECT_TIMEOUT", `TCP/TLS connect to ${host}:${String(port)} did not complete within ${String(CONNECT_TIMEOUT_MS)}ms`));
686
+ }, CONNECT_TIMEOUT_MS);
687
+ dialTimer.unref();
688
+ socket.once("secureConnect", () => {
689
+ clearTimeout(dialTimer);
656
690
  resolve(socket);
657
691
  });
658
- socket.once("error", reject);
692
+ socket.once("error", (err) => {
693
+ clearTimeout(dialTimer);
694
+ reject(err);
695
+ });
659
696
  });
660
697
  /**
661
698
  * A single authenticated socket to ONE cTrader host (live or demo).
@@ -699,18 +736,42 @@ var HostSocket = class {
699
736
  this.connectFn = connectFn;
700
737
  this.root = root;
701
738
  }
702
- /** Connect the socket and run the app-auth handshake. Idempotent. */
739
+ /**
740
+ * Connect the socket and run the app-auth handshake. Idempotent.
741
+ *
742
+ * Foreground waits are bounded by `START_WAIT_TIMEOUT_MS`: if the connection
743
+ * (initial or reconnect) is not up in time the CALLER gets a retryable
744
+ * `CONNECTION_UNAVAILABLE` while the attempt keeps going in the background —
745
+ * the next call joins the same in-flight attempt. Without this bound, a
746
+ * caller arriving during an outage awaited the infinite reconnect loop and
747
+ * hung until the MCP client's 60s abort.
748
+ */
703
749
  async start() {
704
750
  if (this.closing) throw new CTraderError("CLIENT_CLOSED", "Client is shutting down");
705
751
  if (this.conn) return;
706
- if (this.reconnectPromise) return this.reconnectPromise;
707
- if (this.connectPromise) return this.connectPromise;
752
+ const pending = this.reconnectPromise ?? this.connectPromise;
753
+ if (pending) return this.waitForConnection(pending);
708
754
  const attempt = this.establishConnection();
709
755
  this.connectPromise = attempt;
756
+ attempt.then(() => {
757
+ if (this.connectPromise === attempt) this.connectPromise = null;
758
+ }, () => {
759
+ if (this.connectPromise === attempt) this.connectPromise = null;
760
+ });
761
+ return this.waitForConnection(attempt);
762
+ }
763
+ /** Await a connection attempt, giving up (without cancelling it) at the deadline. */
764
+ async waitForConnection(pending) {
765
+ let deadline;
710
766
  try {
711
- await attempt;
767
+ await Promise.race([pending, new Promise((_, reject) => {
768
+ deadline = setTimeout(() => {
769
+ reject(new CTraderError("CONNECTION_UNAVAILABLE", `No connection to ${this.host} within ${String(START_WAIT_TIMEOUT_MS)}ms — still ${this.reconnectPromise ? "reconnecting" : "connecting"} in the background, retry shortly`));
770
+ }, START_WAIT_TIMEOUT_MS);
771
+ deadline.unref();
772
+ })]);
712
773
  } finally {
713
- if (this.connectPromise === attempt) this.connectPromise = null;
774
+ if (deadline) clearTimeout(deadline);
714
775
  }
715
776
  }
716
777
  async establishConnection() {
@@ -1098,7 +1159,7 @@ var CTraderPool = class {
1098
1159
  } catch (err) {
1099
1160
  if (retryOnAuthFailure && err instanceof CTraderError && err.errorCode === ACCESS_TOKEN_INVALID && this.refresher !== void 0 && config.accountIdentifier.length > 0) {
1100
1161
  try {
1101
- await this.refreshGrant(config.accountIdentifier);
1162
+ await this.waitForRefresh(this.refreshGrant(config.accountIdentifier));
1102
1163
  } catch (refreshErr) {
1103
1164
  log$1(`Token refresh failed for account ${accountId}: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
1104
1165
  throw err;
@@ -1121,9 +1182,31 @@ var CTraderPool = class {
1121
1182
  const promise = this.refreshGrantInner(accountIdentifier).finally(() => {
1122
1183
  if (this.inFlightRefreshByGrant.get(accountIdentifier) === promise) this.inFlightRefreshByGrant.delete(accountIdentifier);
1123
1184
  });
1185
+ promise.catch(() => void 0);
1124
1186
  this.inFlightRefreshByGrant.set(accountIdentifier, promise);
1125
1187
  return promise;
1126
1188
  }
1189
+ /**
1190
+ * Await an in-flight grant refresh, giving up (without cancelling it) after
1191
+ * `REQUEST_TIMEOUT_MS`. A slow refresh hop must not stack onto the caller's
1192
+ * request budget — the caller falls back to the original
1193
+ * `CH_ACCESS_TOKEN_INVALID` — while the refresh keeps running in the
1194
+ * background: when it lands it rewrites the registry token, so the NEXT
1195
+ * call skips the refresh entirely and fits the normal budget.
1196
+ */
1197
+ async waitForRefresh(pending) {
1198
+ let deadline;
1199
+ try {
1200
+ await Promise.race([pending, new Promise((_, reject) => {
1201
+ deadline = setTimeout(() => {
1202
+ reject(new CTraderError("REFRESH_TIMEOUT", `Token refresh did not complete within ${String(REQUEST_TIMEOUT_MS)}ms — it continues in the background`));
1203
+ }, REQUEST_TIMEOUT_MS);
1204
+ deadline.unref();
1205
+ })]);
1206
+ } finally {
1207
+ if (deadline) clearTimeout(deadline);
1208
+ }
1209
+ }
1127
1210
  async refreshGrantInner(accountIdentifier) {
1128
1211
  if (this.refresher === void 0) throw new CTraderError("NO_REFRESHER", "No cTrader token refresher is configured");
1129
1212
  const { accessToken } = await this.refresher.refreshCTraderAccount(accountIdentifier);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/ctrader-mcp",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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",