@alfe.ai/ctrader-mcp 0.3.7 → 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.
Files changed (2) hide show
  1. package/dist/server.js +67 -8
  2. 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,6 +587,18 @@ 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, …).
@@ -1019,12 +1032,19 @@ var CTraderPool = class {
1019
1032
  registry;
1020
1033
  connectFn;
1021
1034
  root;
1035
+ refresher;
1022
1036
  /** host → the single socket serving that host. Lazily populated. */
1023
1037
  sockets = /* @__PURE__ */ new Map();
1024
- constructor(registry, connectFn = tlsConnect, root = loadRoot()) {
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) {
1025
1044
  this.registry = registry;
1026
1045
  this.connectFn = connectFn;
1027
1046
  this.root = root;
1047
+ this.refresher = refresher;
1028
1048
  }
1029
1049
  /** The account registry (read-only view for tools that list/resolve). */
1030
1050
  get accounts() {
@@ -1065,14 +1085,53 @@ var CTraderPool = class {
1065
1085
  * Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
1066
1086
  * (fail closed — never fall back to another account).
1067
1087
  */
1068
- async request(accountId, payloadType, payload) {
1088
+ async request(accountId, payloadType, payload, retryOnAuthFailure = true) {
1069
1089
  const config = this.resolve(accountId);
1070
1090
  if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
1071
- const socket = this.socketFor(config);
1072
- await socket.authenticateAccount(config.accountId, config.accessToken);
1073
- return socket.request(payloadType, {
1074
- ...payload,
1075
- ctidTraderAccountId: accountIdLong(config.accountId)
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
1076
1135
  });
1077
1136
  }
1078
1137
  /**
@@ -2229,7 +2288,7 @@ async function main() {
2229
2288
  else log(`Failed to resolve cTrader accounts: ${err instanceof Error ? err.message : String(err)}`);
2230
2289
  process.exit(1);
2231
2290
  }
2232
- const pool = new CTraderPool(registry);
2291
+ const pool = new CTraderPool(registry, void 0, void 0, apiClient);
2233
2292
  const server = new McpServer({
2234
2293
  name: "ctrader-mcp-server",
2235
2294
  version: packageVersion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/ctrader-mcp",
3
- "version": "0.3.7",
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.15.0",
24
+ "@alfe.ai/agent-api-client": "0.16.0",
25
25
  "@alfe.ai/config": "0.4.1"
26
26
  },
27
27
  "license": "UNLICENSED",