@alfe.ai/ctrader-mcp 0.3.6 → 0.3.8
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 +94 -17
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -107,6 +107,7 @@ function buildRegistry(creds) {
|
|
|
107
107
|
clientSecret,
|
|
108
108
|
accessToken,
|
|
109
109
|
accountId,
|
|
110
|
+
accountIdentifier: account.accountIdentifier?.trim() ?? "",
|
|
110
111
|
host,
|
|
111
112
|
isLive,
|
|
112
113
|
...account.brokerName != null ? { brokerName: account.brokerName } : {},
|
|
@@ -586,28 +587,58 @@ var CTraderError = class extends Error {
|
|
|
586
587
|
}
|
|
587
588
|
};
|
|
588
589
|
/**
|
|
590
|
+
* cTrader's token-expired reject code. cTrader access tokens live ~30 days and
|
|
591
|
+
* the credentials read serves the STORED token without refreshing, so a
|
|
592
|
+
* long-lived agent's token eventually expires and every account-auth returns
|
|
593
|
+
* this code (Sentry LOCAL-MCP-5 / -A / -B). It is the sole trigger for a
|
|
594
|
+
* refresh + single retry. It is a pre-execution auth rejection whichever
|
|
595
|
+
* request carries it — the OA_ACCOUNT_AUTH_REQ handshake, or the tool request
|
|
596
|
+
* itself on a socket whose memoized account-auth expired mid-session — so
|
|
597
|
+
* retrying the tool call once is safe even for write tools: a request cannot
|
|
598
|
+
* both execute and come back token-invalid.
|
|
599
|
+
*/
|
|
600
|
+
const ACCESS_TOKEN_INVALID = "CH_ACCESS_TOKEN_INVALID";
|
|
601
|
+
/**
|
|
589
602
|
* The unsolicited payload types fanned out to `onEvent` listeners. Everything
|
|
590
603
|
* else without a clientMsgId waiter is still silently dropped (heartbeats,
|
|
591
604
|
* execution events for other sessions, …).
|
|
592
605
|
*/
|
|
593
606
|
const LISTENABLE_EVENTS = new Set([PayloadType.OA_DEPTH_EVENT, PayloadType.OA_SPOT_EVENT]);
|
|
594
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Halved from 10s so the read-idle watchdog can detect a wedged socket before
|
|
609
|
+
* `REQUEST_TIMEOUT_MS` fires, while still tolerating two consecutive missed
|
|
610
|
+
* echoes (see `READ_IDLE_TIMEOUT_MS`). cTrader expects a heartbeat at least
|
|
611
|
+
* every 10s and drops an idle connection at ~30s, so 5s is well within
|
|
612
|
+
* protocol expectations. Heartbeats are protocol keepalives, not part of the
|
|
613
|
+
* historical-data payload class that gets rate limited.
|
|
614
|
+
*/
|
|
615
|
+
const HEARTBEAT_INTERVAL_MS = 5e3;
|
|
595
616
|
const REQUEST_TIMEOUT_MS = 2e4;
|
|
596
617
|
const RECONNECT_BASE_MS = 1e3;
|
|
597
618
|
const RECONNECT_MAX_MS = 3e4;
|
|
598
619
|
const MAX_PENDING_REQUESTS = 256;
|
|
599
620
|
/**
|
|
600
|
-
* Read-idle watchdog: cTrader echoes our
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
* heartbeat echo doesn't false-positive.
|
|
621
|
+
* Read-idle watchdog: cTrader echoes our heartbeats and pushes its own traffic,
|
|
622
|
+
* so a *healthy* socket is never silent for long. If NO inbound byte arrives
|
|
623
|
+
* for this long we treat the socket as silently half-dead (a TCP half-open with
|
|
624
|
+
* no FIN — the OS never fires `close`, so `handleDrop` never runs and every
|
|
625
|
+
* request would otherwise time out indefinitely) and force a reconnect. Set to
|
|
626
|
+
* 3× the heartbeat interval so two dropped heartbeat echoes don't false-positive.
|
|
607
627
|
*/
|
|
608
628
|
const READ_IDLE_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS;
|
|
609
629
|
/** How often the watchdog checks the read-idle clock. */
|
|
610
|
-
const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
|
|
630
|
+
const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS / 2;
|
|
631
|
+
/**
|
|
632
|
+
* Detection must beat the request timeout, or the watchdog is useless to the
|
|
633
|
+
* caller that trips it: a wedged socket surfaces an ambiguous `REQUEST_TIMEOUT`
|
|
634
|
+
* (indistinguishable from a slow server) instead of the fast, honest,
|
|
635
|
+
* retryable `CONNECTION_DROPPED` the reconnect path exists to produce.
|
|
636
|
+
*
|
|
637
|
+
* That was the shipped state — 30s idle + 10s poll = up to 40s to detect, vs a
|
|
638
|
+
* 20s request timeout — so the 0.3.1 watchdog never spared the first caller.
|
|
639
|
+
* Worst-case detection is READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS.
|
|
640
|
+
*/
|
|
641
|
+
if (READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS >= REQUEST_TIMEOUT_MS) throw new Error(`ctrader-mcp timing misconfigured: read-idle detection (${String(READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS)}ms) must be faster than REQUEST_TIMEOUT_MS (${String(REQUEST_TIMEOUT_MS)}ms).`);
|
|
611
642
|
function log$1(msg) {
|
|
612
643
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
613
644
|
}
|
|
@@ -1001,12 +1032,19 @@ var CTraderPool = class {
|
|
|
1001
1032
|
registry;
|
|
1002
1033
|
connectFn;
|
|
1003
1034
|
root;
|
|
1035
|
+
refresher;
|
|
1004
1036
|
/** host → the single socket serving that host. Lazily populated. */
|
|
1005
1037
|
sockets = /* @__PURE__ */ new Map();
|
|
1006
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* grant accountIdentifier → in-flight refresh. Dedupes concurrent tool calls
|
|
1040
|
+
* that all trip an expired token on the same grant into ONE backend refresh.
|
|
1041
|
+
*/
|
|
1042
|
+
inFlightRefreshByGrant = /* @__PURE__ */ new Map();
|
|
1043
|
+
constructor(registry, connectFn = tlsConnect, root = loadRoot(), refresher) {
|
|
1007
1044
|
this.registry = registry;
|
|
1008
1045
|
this.connectFn = connectFn;
|
|
1009
1046
|
this.root = root;
|
|
1047
|
+
this.refresher = refresher;
|
|
1010
1048
|
}
|
|
1011
1049
|
/** The account registry (read-only view for tools that list/resolve). */
|
|
1012
1050
|
get accounts() {
|
|
@@ -1047,14 +1085,53 @@ var CTraderPool = class {
|
|
|
1047
1085
|
* Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
|
|
1048
1086
|
* (fail closed — never fall back to another account).
|
|
1049
1087
|
*/
|
|
1050
|
-
async request(accountId, payloadType, payload) {
|
|
1088
|
+
async request(accountId, payloadType, payload, retryOnAuthFailure = true) {
|
|
1051
1089
|
const config = this.resolve(accountId);
|
|
1052
1090
|
if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1091
|
+
try {
|
|
1092
|
+
const socket = this.socketFor(config);
|
|
1093
|
+
await socket.authenticateAccount(config.accountId, config.accessToken);
|
|
1094
|
+
return await socket.request(payloadType, {
|
|
1095
|
+
...payload,
|
|
1096
|
+
ctidTraderAccountId: accountIdLong(config.accountId)
|
|
1097
|
+
});
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
if (retryOnAuthFailure && err instanceof CTraderError && err.errorCode === ACCESS_TOKEN_INVALID && this.refresher !== void 0 && config.accountIdentifier.length > 0) {
|
|
1100
|
+
try {
|
|
1101
|
+
await this.refreshGrant(config.accountIdentifier);
|
|
1102
|
+
} catch (refreshErr) {
|
|
1103
|
+
log$1(`Token refresh failed for account ${accountId}: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
1104
|
+
throw err;
|
|
1105
|
+
}
|
|
1106
|
+
return this.request(accountId, payloadType, payload, false);
|
|
1107
|
+
}
|
|
1108
|
+
throw err;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Refresh one grant's token, deduping concurrent callers on the same grant.
|
|
1113
|
+
* A grant token covers EVERY account under that cTrader login, so refreshing
|
|
1114
|
+
* once and rewriting the token on all registry entries sharing the grant is
|
|
1115
|
+
* both correct and avoids N redundant refreshes when several accounts trip
|
|
1116
|
+
* the expiry at once.
|
|
1117
|
+
*/
|
|
1118
|
+
refreshGrant(accountIdentifier) {
|
|
1119
|
+
const inFlight = this.inFlightRefreshByGrant.get(accountIdentifier);
|
|
1120
|
+
if (inFlight !== void 0) return inFlight;
|
|
1121
|
+
const promise = this.refreshGrantInner(accountIdentifier).finally(() => {
|
|
1122
|
+
if (this.inFlightRefreshByGrant.get(accountIdentifier) === promise) this.inFlightRefreshByGrant.delete(accountIdentifier);
|
|
1123
|
+
});
|
|
1124
|
+
this.inFlightRefreshByGrant.set(accountIdentifier, promise);
|
|
1125
|
+
return promise;
|
|
1126
|
+
}
|
|
1127
|
+
async refreshGrantInner(accountIdentifier) {
|
|
1128
|
+
if (this.refresher === void 0) throw new CTraderError("NO_REFRESHER", "No cTrader token refresher is configured");
|
|
1129
|
+
const { accessToken } = await this.refresher.refreshCTraderAccount(accountIdentifier);
|
|
1130
|
+
const newToken = accessToken.trim();
|
|
1131
|
+
if (newToken.length === 0) throw new CTraderError("REFRESH_FAILED", "cTrader token refresh returned an empty access token");
|
|
1132
|
+
for (const [id, cfg] of this.registry) if (cfg.accountIdentifier === accountIdentifier) this.registry.set(id, {
|
|
1133
|
+
...cfg,
|
|
1134
|
+
accessToken: newToken
|
|
1058
1135
|
});
|
|
1059
1136
|
}
|
|
1060
1137
|
/**
|
|
@@ -2211,7 +2288,7 @@ async function main() {
|
|
|
2211
2288
|
else log(`Failed to resolve cTrader accounts: ${err instanceof Error ? err.message : String(err)}`);
|
|
2212
2289
|
process.exit(1);
|
|
2213
2290
|
}
|
|
2214
|
-
const pool = new CTraderPool(registry);
|
|
2291
|
+
const pool = new CTraderPool(registry, void 0, void 0, apiClient);
|
|
2215
2292
|
const server = new McpServer({
|
|
2216
2293
|
name: "ctrader-mcp-server",
|
|
2217
2294
|
version: packageVersion
|
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.8",
|
|
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.16.0",
|
|
25
25
|
"@alfe.ai/config": "0.4.1"
|
|
26
26
|
},
|
|
27
27
|
"license": "UNLICENSED",
|