@alfe.ai/ctrader-mcp 0.3.9 → 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.
- package/dist/server.js +92 -9
- package/package.json +2 -2
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",
|
|
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
|
-
/**
|
|
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
|
-
|
|
707
|
-
if (
|
|
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
|
|
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 (
|
|
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.
|
|
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",
|
|
@@ -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.
|
|
24
|
+
"@alfe.ai/agent-api-client": "0.17.0",
|
|
25
25
|
"@alfe.ai/config": "0.4.1"
|
|
26
26
|
},
|
|
27
27
|
"license": "UNLICENSED",
|