@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 +45 -29
- package/package.json +2 -2
- package/proto/ctrader.proto +48 -29
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()
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
62
|
-
*
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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()`
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
24
|
+
"@alfe.ai/agent-api-client": "0.11.0",
|
|
25
25
|
"@alfe.ai/config": "0.3.0"
|
|
26
26
|
},
|
|
27
27
|
"license": "UNLICENSED",
|
package/proto/ctrader.proto
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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 =
|
|
203
|
-
optional double executionPrice =
|
|
204
|
-
optional int64 executedVolume =
|
|
205
|
-
optional int64 utcLastUpdateTimestamp =
|
|
206
|
-
optional double baseSlippagePrice =
|
|
207
|
-
optional int64 slippageInPoints =
|
|
208
|
-
optional bool closingOrder =
|
|
209
|
-
optional double limitPrice =
|
|
210
|
-
optional double stopPrice =
|
|
211
|
-
optional double stopLoss =
|
|
212
|
-
optional double takeProfit =
|
|
213
|
-
optional string clientOrderId =
|
|
214
|
-
optional uint32 timeInForce =
|
|
215
|
-
optional int64 positionId =
|
|
216
|
-
optional
|
|
217
|
-
optional
|
|
218
|
-
optional bool isStopOut =
|
|
219
|
-
optional bool trailingStopLoss =
|
|
220
|
-
optional uint32 stopTriggerMethod =
|
|
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
|
-
|
|
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 =
|
|
431
|
-
optional bool isServerEvent =
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
optional int64 positionId =
|
|
446
|
-
optional string description =
|
|
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
|
}
|