@alfe.ai/ctrader-mcp 0.1.0 → 0.2.0
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/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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|