@alfe.ai/ctrader-mcp 0.1.0 → 0.2.1

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 CHANGED
@@ -16,14 +16,18 @@ import { z } from "zod";
16
16
  *
17
17
  * Like the other Alfe MCP servers, this one self-fetches its credentials at
18
18
  * startup via `@alfe.ai/agent-api-client`. The primary path is the
19
- * multi-account accessor `getCTraderAccounts()`: a single cTrader OAuth grant
20
- * covers ALL of the user's trading accounts on one shared `accessToken`, with
21
- * the SST-global app credentials (`clientId`/`clientSecret`) hoisted to the top
22
- * level. Only the `ctidTraderAccountId` and the protobuf socket `host` (live vs
23
- * demo) differ per account. We build an account *registry* from that set here
24
- * one entry per account, grouped by host so a distinct socket serves each
25
- * distinct host (live vs demo). The cTrader Open API app-auth handshake needs
26
- * the raw client id/secret + access token on the box, which is why the connect
19
+ * multi-account accessor `getCTraderAccounts()`, which aggregates trading
20
+ * accounts across ALL of the agent's cTrader OAuth grants (multiple distinct
21
+ * logins). Each account carries ITS OWN grant's `accessToken`; the SST-global
22
+ * app credentials (`clientId`/`clientSecret`) are identical across grants and
23
+ * hoisted to the top level. Only the `ctidTraderAccountId` and the protobuf
24
+ * socket `host` (live vs demo) differ within a grant. We build an account
25
+ * *registry* from that set here one entry per account, each stamped with its
26
+ * own token, grouped by host so a distinct socket serves each distinct host
27
+ * (live vs demo). App-auth uses the global `clientId`/`clientSecret`, so one
28
+ * live/demo socket serves accounts from multiple grants, each account-authed
29
+ * with its own token. The cTrader Open API app-auth handshake needs the raw
30
+ * client id/secret + access token on the box, which is why the connect
27
31
  * endpoint returns the full set rather than brokering each socket request.
28
32
  */
29
33
  /** cTrader Open API TLS endpoints. Port is always 5035. */
@@ -58,29 +62,32 @@ function resolveHost(raw) {
58
62
  *
59
63
  * Throws `ConfigError` (→ MCP startup failure, fail closed) when:
60
64
  * - `accounts` is empty (no cTrader Connection — there is nothing to trade);
61
- * - the shared app credentials (`clientId`/`clientSecret`/`accessToken`) are
62
- * missing (every account needs them to authenticate);
65
+ * - the shared app credentials (`clientId`/`clientSecret`) are missing (every
66
+ * account needs them to app-authenticate);
63
67
  * - after validating each account, no valid account remains.
64
68
  *
65
- * Individual accounts with an unparseable `ctidTraderAccountId` are skipped
66
- * (logged by the caller), but if that leaves the registry empty we fail closed
67
- * rather than start a server that can trade on nothing.
69
+ * Each account carries its OWN grant's `accessToken` (accounts are aggregated
70
+ * across multiple cTrader OAuth grants). Individual accounts with an
71
+ * unparseable `ctidTraderAccountId` OR a missing `accessToken` are skipped
72
+ * (the caller warns on the input-vs-registry-size count diff — see server.ts)
73
+ * rather than poisoning the whole registry, but if that leaves the registry
74
+ * empty we fail closed rather than start a server that can trade on nothing.
68
75
  */
69
76
  function buildRegistry(creds) {
70
77
  const clientId = creds.clientId.trim();
71
78
  const clientSecret = creds.clientSecret.trim();
72
- const accessToken = creds.accessToken.trim();
73
79
  if (creds.accounts.length === 0) throw new ConfigError("No cTrader trading accounts are authorized for this agent. Connect a cTrader account (OAuth) for this agent, then retry.");
74
80
  const missing = [];
75
81
  if (!clientId) missing.push("clientId");
76
82
  if (!clientSecret) missing.push("clientSecret");
77
- if (!accessToken) missing.push("accessToken");
78
83
  if (missing.length > 0) throw new ConfigError(`The cTrader connection is missing shared credential field(s): ${missing.join(", ")}. Connect a cTrader account (OAuth) for this agent, then retry.`);
79
84
  const registry = /* @__PURE__ */ new Map();
80
85
  for (const account of creds.accounts) {
81
86
  const idRaw = account.ctidTraderAccountId.trim();
82
87
  const accountId = Number(idRaw);
83
88
  if (!idRaw || !Number.isInteger(accountId) || accountId <= 0) continue;
89
+ const accessToken = account.accessToken.trim();
90
+ if (!accessToken) continue;
84
91
  const host = resolveHost(account.host);
85
92
  registry.set(idRaw, {
86
93
  clientId,
@@ -93,7 +100,7 @@ function buildRegistry(creds) {
93
100
  ...account.accountNumber != null ? { accountNumber: account.accountNumber } : {}
94
101
  });
95
102
  }
96
- if (registry.size === 0) throw new ConfigError("Every cTrader account returned had an unusable ctidTraderAccountId; cannot build a trading registry. Re-connect the cTrader account (OAuth).");
103
+ if (registry.size === 0) throw new ConfigError("Every cTrader account returned had an unusable ctidTraderAccountId or was missing its access token; cannot build a trading registry. Re-connect the cTrader account (OAuth).");
97
104
  return registry;
98
105
  }
99
106
  //#endregion
@@ -371,9 +378,12 @@ function decodeTrendbar(bar) {
371
378
  * 10-second heartbeat, reconnect-on-drop, and cTrader-error mapping.
372
379
  *
373
380
  * ── Multi-account model ──
374
- * A single cTrader OAuth grant covers ALL of the user's trading accounts on one
375
- * shared access token; only the `ctidTraderAccountId` and the socket `host`
376
- * (live vs demo) differ per account. But one TLS socket dials exactly ONE host.
381
+ * Within a SINGLE cTrader OAuth grant one shared access token covers all of that
382
+ * login's accounts; ACROSS grants the tokens differ, so each account carries its
383
+ * OWN grant token (`authedAccounts` memoizes account-auth per accountId, keyed by
384
+ * that token — a changed token re-auths, so mixed-grant tokens on one host socket
385
+ * work with zero extra code). Only the `ctidTraderAccountId` and the socket `host`
386
+ * (live vs demo) drive routing. But one TLS socket dials exactly ONE host.
377
387
  * So the topology is:
378
388
  *
379
389
  * CTraderPool ── owns the account registry, one HostSocket per DISTINCT host
@@ -1258,17 +1268,20 @@ function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
1258
1268
  * Architecture:
1259
1269
  * Agent runtime ←(stdio/MCP)→ this server ←(protobuf/TLS 5035)→ cTrader Open API
1260
1270
  *
1261
- * ── Multi-account ──
1271
+ * ── Multi-account, multi-grant ──
1262
1272
  * Credentials are self-fetched at startup (the atlassian/google pattern):
1263
1273
  * `resolveConfig()` yields the agent's `{ apiKey, apiUrl }`, and
1264
- * `AgentApiClient.getCTraderAccounts()` returns the full set of authorized
1265
- * trading accounts on one OAuth grant one shared `accessToken` +
1266
- * SST-global `clientId`/`clientSecret`, with a per-account `ctidTraderAccountId`
1267
- * and `host` (live vs demo). We build an account registry and drive a
1268
- * `CTraderPool` that opens one authenticated socket per distinct host and
1269
- * routes each tool call to the account it names (see client.ts). No account is
1270
- * ever chosen implicitly when several are connected the tools require an
1271
- * explicit `accountId` in that case.
1274
+ * `AgentApiClient.getCTraderAccounts()` aggregates the authorized trading
1275
+ * accounts across ALL of the agent's cTrader OAuth grants (multiple distinct
1276
+ * logins). Each account carries ITS OWN grant's `accessToken`; only the global
1277
+ * app credentials (`clientId`/`clientSecret`) are shared across every grant and
1278
+ * hoisted to the top level. A per-account `ctidTraderAccountId` and `host`
1279
+ * (live vs demo) drive routing. We build an account registry and drive a
1280
+ * `CTraderPool` that opens one authenticated socket per distinct host each
1281
+ * account is account-authed on that socket with its OWN token, so one live/demo
1282
+ * socket serves accounts from multiple grants — and routes each tool call to the
1283
+ * account it names (see client.ts). No account is ever chosen implicitly when
1284
+ * several are connected — the tools require an explicit `accountId` in that case.
1272
1285
  */
1273
1286
  function log(msg) {
1274
1287
  process.stderr.write(`[ctrader-mcp] ${msg}\n`);
@@ -1281,7 +1294,10 @@ async function main() {
1281
1294
  });
1282
1295
  let registry;
1283
1296
  try {
1284
- registry = buildRegistry(await apiClient.getCTraderAccounts());
1297
+ const creds = await apiClient.getCTraderAccounts();
1298
+ registry = buildRegistry(creds);
1299
+ const dropped = creds.accounts.length - registry.size;
1300
+ if (dropped > 0) log(`Warning: ${String(dropped)} of ${String(creds.accounts.length)} cTrader account(s) were skipped (unparseable ctidTraderAccountId or missing access token); ${String(registry.size)} usable.`);
1285
1301
  } catch (err) {
1286
1302
  if (err instanceof ConfigError) log(`No usable cTrader connection: ${err.message}`);
1287
1303
  else log(`Failed to resolve cTrader accounts: ${err instanceof Error ? err.message : String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/ctrader-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "cTrader MCP server — full trading (place/modify/close orders + read) 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.10.0",
24
+ "@alfe.ai/agent-api-client": "0.11.0",
25
25
  "@alfe.ai/config": "0.3.0"
26
26
  },
27
27
  "license": "UNLICENSED",
@@ -119,11 +119,16 @@ message ProtoOACtidTraderAccount {
119
119
  optional string brokerTitleShort = 6;
120
120
  }
121
121
 
122
+ // Canonical: permissionScope=3 (an enum upstream; we don't read it, kept as a
123
+ // scalar), ctidTraderAccount=4. This response is declared for completeness but
124
+ // is NOT on the live path — accounts come from AgentApiClient.getCTraderAccounts(),
125
+ // not this socket call. Field numbers corrected to match canonical anyway so a
126
+ // future decode of this message reads the account list at the right field.
122
127
  message ProtoOAGetAccountListByAccessTokenRes {
123
128
  optional uint32 payloadType = 1;
124
129
  required string accessToken = 2;
125
- repeated ProtoOACtidTraderAccount ctidTraderAccount = 3;
126
- optional bool permissionScope = 4;
130
+ optional uint32 permissionScope = 3;
131
+ repeated ProtoOACtidTraderAccount ctidTraderAccount = 4;
127
132
  }
128
133
 
129
134
  // ── Trader (balance / account details) ──────────────────────────────────
@@ -194,30 +199,33 @@ message ProtoOAPosition {
194
199
  optional bool trailingStopLoss = 16;
195
200
  }
196
201
 
202
+ // NOTE: canonical ProtoOAOrder has NO field 5 — it jumps orderStatus=4 → expirationTimestamp=6.
203
+ // Field numbers below are transcribed verbatim from Spotware's ProtoOAOrder
204
+ // (relativeStopLoss/relativeTakeProfit are int64, not double, upstream).
197
205
  message ProtoOAOrder {
198
206
  required int64 orderId = 1;
199
207
  required ProtoOATradeData tradeData = 2;
200
208
  required ProtoOAOrderType orderType = 3;
201
209
  required uint32 orderStatus = 4;
202
- optional int64 expirationTimestamp = 5;
203
- optional double executionPrice = 6;
204
- optional int64 executedVolume = 7;
205
- optional int64 utcLastUpdateTimestamp = 8;
206
- optional double baseSlippagePrice = 9;
207
- optional int64 slippageInPoints = 10;
208
- optional bool closingOrder = 11;
209
- optional double limitPrice = 12;
210
- optional double stopPrice = 13;
211
- optional double stopLoss = 14;
212
- optional double takeProfit = 15;
213
- optional string clientOrderId = 16;
214
- optional uint32 timeInForce = 17;
215
- optional int64 positionId = 18;
216
- optional double relativeStopLoss = 19;
217
- optional double relativeTakeProfit = 20;
218
- optional bool isStopOut = 21;
219
- optional bool trailingStopLoss = 22;
220
- optional uint32 stopTriggerMethod = 23;
210
+ optional int64 expirationTimestamp = 6;
211
+ optional double executionPrice = 7;
212
+ optional int64 executedVolume = 8;
213
+ optional int64 utcLastUpdateTimestamp = 9;
214
+ optional double baseSlippagePrice = 10;
215
+ optional int64 slippageInPoints = 11;
216
+ optional bool closingOrder = 12;
217
+ optional double limitPrice = 13;
218
+ optional double stopPrice = 14;
219
+ optional double stopLoss = 15;
220
+ optional double takeProfit = 16;
221
+ optional string clientOrderId = 17;
222
+ optional uint32 timeInForce = 18;
223
+ optional int64 positionId = 19;
224
+ optional int64 relativeStopLoss = 20;
225
+ optional int64 relativeTakeProfit = 21;
226
+ optional bool isStopOut = 22;
227
+ optional bool trailingStopLoss = 23;
228
+ optional uint32 stopTriggerMethod = 24;
221
229
  }
222
230
 
223
231
  message ProtoOAReconcileReq {
@@ -278,7 +286,10 @@ message ProtoOASymbol {
278
286
  optional int64 minVolume = 10;
279
287
  optional int64 stepVolume = 11;
280
288
  optional int64 maxExposure = 12;
281
- optional int64 lotSize = 21;
289
+ // lotSize is canonical field 30 (NOT 21 — field 21 is the deprecated
290
+ // minCommission). Reading it at 21 yields 0 for XAUUSD → SYMBOL_NO_LOTSIZE
291
+ // and mis-sizes volume for any symbol where field 21 is non-zero.
292
+ optional int64 lotSize = 30;
282
293
  }
283
294
 
284
295
  message ProtoOASymbolByIdReq {
@@ -421,14 +432,18 @@ message ProtoOACancelOrderReq {
421
432
  required int64 orderId = 3;
422
433
  }
423
434
 
435
+ // errorCode is canonical field 9 and isServerEvent field 10 (fields 6/7/8 are
436
+ // deal / bonusDepositWithdraw / depositWithdraw upstream, which we don't decode).
437
+ // An order-op rejection can ride here with errorCode set, so the field number
438
+ // is on the money path — reading errorCode at 8 misses the reject entirely.
424
439
  message ProtoOAExecutionEvent {
425
440
  optional uint32 payloadType = 1;
426
441
  required int64 ctidTraderAccountId = 2;
427
442
  required uint32 executionType = 3;
428
443
  optional ProtoOAPosition position = 4;
429
444
  optional ProtoOAOrder order = 5;
430
- optional string errorCode = 8;
431
- optional bool isServerEvent = 9;
445
+ optional string errorCode = 9;
446
+ optional bool isServerEvent = 10;
432
447
  }
433
448
 
434
449
  // ── Order-error event (payloadType 2132) ────────────────────────────────
@@ -437,11 +452,15 @@ message ProtoOAExecutionEvent {
437
452
  // hours, insufficient margin, etc.). Field numbers/types transcribed from
438
453
  // Spotware's ProtoOAOrderErrorEvent (OpenApiMessages.proto). This is a REJECT
439
454
  // on a money path — it MUST map to a CTraderError, never resolve as success.
455
+ // Canonical field numbers are NOT sequential: errorCode=2, orderId=3,
456
+ // ctidTraderAccountId=5, positionId=6, description=7. Getting these wrong
457
+ // means the reject decodes to an empty errorCode and fails to map to a
458
+ // CTraderError — reintroducing the 2132 phantom-success bug.
440
459
  message ProtoOAOrderErrorEvent {
441
460
  optional uint32 payloadType = 1;
442
- required int64 ctidTraderAccountId = 2;
443
- required string errorCode = 3;
444
- optional int64 orderId = 4;
445
- optional int64 positionId = 5;
446
- optional string description = 6;
461
+ required string errorCode = 2;
462
+ optional int64 orderId = 3;
463
+ required int64 ctidTraderAccountId = 5;
464
+ optional int64 positionId = 6;
465
+ optional string description = 7;
447
466
  }