@alfe.ai/ctrader-mcp 0.3.5 → 0.3.6
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 +381 -145
- package/package.json +3 -3
- package/proto/ctrader.proto +32 -24
package/dist/server.js
CHANGED
|
@@ -6,9 +6,9 @@ import { resolveConfig } from "@alfe.ai/config";
|
|
|
6
6
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as tls from "node:tls";
|
|
9
|
+
import protobuf from "protobufjs";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
|
-
import protobuf from "protobufjs";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
//#region src/config.ts
|
|
14
14
|
/**
|
|
@@ -41,6 +41,15 @@ var ConfigError = class extends Error {
|
|
|
41
41
|
this.name = "ConfigError";
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
|
+
const MAX_INT64 = 9223372036854775807n;
|
|
45
|
+
function parseAccountId(raw) {
|
|
46
|
+
if (!/^[1-9][0-9]*$/.test(raw)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return BigInt(raw) <= MAX_INT64 ? raw : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
/**
|
|
45
54
|
* Resolve the TLS host from a live/demo hint.
|
|
46
55
|
*
|
|
@@ -84,18 +93,22 @@ function buildRegistry(creds) {
|
|
|
84
93
|
const registry = /* @__PURE__ */ new Map();
|
|
85
94
|
for (const account of creds.accounts) {
|
|
86
95
|
const idRaw = account.ctidTraderAccountId.trim();
|
|
87
|
-
const accountId =
|
|
88
|
-
if (
|
|
96
|
+
const accountId = parseAccountId(idRaw);
|
|
97
|
+
if (accountId == null) continue;
|
|
89
98
|
const accessToken = account.accessToken.trim();
|
|
90
99
|
if (!accessToken) continue;
|
|
91
100
|
const host = resolveHost(account.host);
|
|
92
|
-
|
|
101
|
+
const isLive = host === CTRADER_LIVE_HOST;
|
|
102
|
+
if (typeof account.isLive !== "boolean" || account.isLive !== isLive) throw new ConfigError(`cTrader account ${idRaw} has conflicting host/live metadata; refusing ambiguous real-money routing.`);
|
|
103
|
+
const canonicalId = accountId;
|
|
104
|
+
if (registry.has(canonicalId)) throw new ConfigError(`Duplicate cTrader account ${canonicalId} was returned; refusing ambiguous credential/host routing.`);
|
|
105
|
+
registry.set(canonicalId, {
|
|
93
106
|
clientId,
|
|
94
107
|
clientSecret,
|
|
95
108
|
accessToken,
|
|
96
109
|
accountId,
|
|
97
110
|
host,
|
|
98
|
-
isLive
|
|
111
|
+
isLive,
|
|
99
112
|
...account.brokerName != null ? { brokerName: account.brokerName } : {},
|
|
100
113
|
...account.accountNumber != null ? { accountNumber: account.accountNumber } : {}
|
|
101
114
|
});
|
|
@@ -222,6 +235,36 @@ const RESPONSE_MESSAGE = {
|
|
|
222
235
|
[PayloadType.OA_ORDER_ERROR_EVENT]: "ctrader.ProtoOAOrderErrorEvent",
|
|
223
236
|
[PayloadType.OA_ERROR_RES]: "ctrader.ProtoOAErrorRes"
|
|
224
237
|
};
|
|
238
|
+
/**
|
|
239
|
+
* The only successful correlated reply accepted for each request. cTrader can
|
|
240
|
+
* also answer any request with one of the error payloads handled by the
|
|
241
|
+
* client. Keeping the success contract explicit prevents an unrelated frame
|
|
242
|
+
* that happens to reuse a clientMsgId from being reported as success.
|
|
243
|
+
*/
|
|
244
|
+
const EXPECTED_RESPONSE = {
|
|
245
|
+
[PayloadType.OA_APPLICATION_AUTH_REQ]: PayloadType.OA_APPLICATION_AUTH_RES,
|
|
246
|
+
[PayloadType.OA_ACCOUNT_AUTH_REQ]: PayloadType.OA_ACCOUNT_AUTH_RES,
|
|
247
|
+
[PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_REQ]: PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES,
|
|
248
|
+
[PayloadType.OA_TRADER_REQ]: PayloadType.OA_TRADER_RES,
|
|
249
|
+
[PayloadType.OA_RECONCILE_REQ]: PayloadType.OA_RECONCILE_RES,
|
|
250
|
+
[PayloadType.OA_SYMBOLS_LIST_REQ]: PayloadType.OA_SYMBOLS_LIST_RES,
|
|
251
|
+
[PayloadType.OA_SYMBOL_BY_ID_REQ]: PayloadType.OA_SYMBOL_BY_ID_RES,
|
|
252
|
+
[PayloadType.OA_GET_TRENDBARS_REQ]: PayloadType.OA_GET_TRENDBARS_RES,
|
|
253
|
+
[PayloadType.OA_SUBSCRIBE_SPOTS_REQ]: PayloadType.OA_SUBSCRIBE_SPOTS_RES,
|
|
254
|
+
[PayloadType.OA_UNSUBSCRIBE_SPOTS_REQ]: PayloadType.OA_UNSUBSCRIBE_SPOTS_RES,
|
|
255
|
+
[PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_REQ]: PayloadType.OA_SUBSCRIBE_DEPTH_QUOTES_RES,
|
|
256
|
+
[PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_REQ]: PayloadType.OA_UNSUBSCRIBE_DEPTH_QUOTES_RES,
|
|
257
|
+
[PayloadType.OA_DEAL_LIST_REQ]: PayloadType.OA_DEAL_LIST_RES,
|
|
258
|
+
[PayloadType.OA_GET_TICKDATA_REQ]: PayloadType.OA_GET_TICKDATA_RES,
|
|
259
|
+
[PayloadType.OA_EXPECTED_MARGIN_REQ]: PayloadType.OA_EXPECTED_MARGIN_RES,
|
|
260
|
+
[PayloadType.OA_CASH_FLOW_HISTORY_LIST_REQ]: PayloadType.OA_CASH_FLOW_HISTORY_LIST_RES,
|
|
261
|
+
[PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ]: PayloadType.OA_GET_POSITION_UNREALIZED_PNL_RES,
|
|
262
|
+
[PayloadType.OA_NEW_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
263
|
+
[PayloadType.OA_AMEND_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
264
|
+
[PayloadType.OA_AMEND_POSITION_SLTP_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
265
|
+
[PayloadType.OA_CLOSE_POSITION_REQ]: PayloadType.OA_EXECUTION_EVENT,
|
|
266
|
+
[PayloadType.OA_CANCEL_ORDER_REQ]: PayloadType.OA_EXECUTION_EVENT
|
|
267
|
+
};
|
|
225
268
|
//#endregion
|
|
226
269
|
//#region src/proto.ts
|
|
227
270
|
/**
|
|
@@ -270,6 +313,45 @@ function loadRoot(protoPath = resolveProtoPath()) {
|
|
|
270
313
|
cachedRoot ??= protobuf.loadSync(protoPath);
|
|
271
314
|
return cachedRoot;
|
|
272
315
|
}
|
|
316
|
+
/** Hard ceiling for one cTrader envelope and the parser's retained buffer. */
|
|
317
|
+
const MAX_FRAME_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
318
|
+
const INT64_FIELD_TYPES = new Set([
|
|
319
|
+
"int64",
|
|
320
|
+
"uint64",
|
|
321
|
+
"sint64",
|
|
322
|
+
"fixed64",
|
|
323
|
+
"sfixed64"
|
|
324
|
+
]);
|
|
325
|
+
function normalizeFieldValue(field, value) {
|
|
326
|
+
if (field.repeated) {
|
|
327
|
+
if (!Array.isArray(value)) return value;
|
|
328
|
+
return value.map((entry) => normalizeSingularFieldValue(field, entry));
|
|
329
|
+
}
|
|
330
|
+
if (field.map) {
|
|
331
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
332
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeSingularFieldValue(field, entry)]));
|
|
333
|
+
}
|
|
334
|
+
return normalizeSingularFieldValue(field, value);
|
|
335
|
+
}
|
|
336
|
+
function normalizeSingularFieldValue(field, value) {
|
|
337
|
+
if (field.resolvedType instanceof protobuf.Type && typeof value === "object" && value !== null && !Array.isArray(value)) return normalizeInt64Strings(field.resolvedType, value);
|
|
338
|
+
if (INT64_FIELD_TYPES.has(field.type) && typeof value === "string") {
|
|
339
|
+
const unsigned = field.type === "uint64" || field.type === "fixed64";
|
|
340
|
+
return protobuf.util.LongBits.from(value).toLong(unsigned);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Convert only protobuf int64 decimal strings to exact Long values before
|
|
346
|
+
* validation. Unlike `fromObject`, this preserves missing required fields so
|
|
347
|
+
* `verify` still rejects malformed payloads instead of filling defaults.
|
|
348
|
+
*/
|
|
349
|
+
function normalizeInt64Strings(type, payload) {
|
|
350
|
+
return Object.fromEntries(Object.entries(payload).map(([name, value]) => {
|
|
351
|
+
if (!Object.hasOwn(type.fields, name)) return [name, value];
|
|
352
|
+
return [name, normalizeFieldValue(type.fields[name], value)];
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
273
355
|
/**
|
|
274
356
|
* Encode an outbound request into a fully-framed buffer:
|
|
275
357
|
* length prefix + ProtoMessage(payloadType, payload, clientMsgId).
|
|
@@ -278,9 +360,16 @@ function encodeRequest(root, payloadType, payload, clientMsgId) {
|
|
|
278
360
|
const messageName = REQUEST_MESSAGE[payloadType];
|
|
279
361
|
if (!messageName) throw new Error(`No request message registered for payloadType ${String(payloadType)}`);
|
|
280
362
|
const InnerType = root.lookupType(messageName);
|
|
281
|
-
|
|
363
|
+
let normalizedPayload;
|
|
364
|
+
try {
|
|
365
|
+
normalizedPayload = normalizeInt64Strings(InnerType, payload);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new Error(`Invalid ${messageName} payload: ${err instanceof Error ? err.message : String(err)}`);
|
|
368
|
+
}
|
|
369
|
+
const innerErr = InnerType.verify(normalizedPayload);
|
|
282
370
|
if (innerErr) throw new Error(`Invalid ${messageName} payload: ${innerErr}`);
|
|
283
|
-
const
|
|
371
|
+
const innerMessage = InnerType.create(normalizedPayload);
|
|
372
|
+
const innerBytes = InnerType.encode(innerMessage).finish();
|
|
284
373
|
const Envelope = root.lookupType("ctrader.ProtoMessage");
|
|
285
374
|
const envelopeBytes = Envelope.encode(Envelope.create({
|
|
286
375
|
payloadType,
|
|
@@ -306,11 +395,19 @@ function decodeEnvelope(root, envelopeBytes) {
|
|
|
306
395
|
var FrameParser = class {
|
|
307
396
|
buffer = Buffer.alloc(0);
|
|
308
397
|
push(chunk) {
|
|
398
|
+
if (this.buffer.length + chunk.byteLength > 16777220) {
|
|
399
|
+
this.buffer = Buffer.alloc(0);
|
|
400
|
+
throw new Error(`cTrader frame buffer exceeds ${String(MAX_FRAME_LENGTH_BYTES)} bytes`);
|
|
401
|
+
}
|
|
309
402
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
310
403
|
const frames = [];
|
|
311
404
|
for (;;) {
|
|
312
405
|
if (this.buffer.length < 4) break;
|
|
313
406
|
const length = this.buffer.readUInt32BE(0);
|
|
407
|
+
if (length === 0 || length > 16777216) {
|
|
408
|
+
this.buffer = Buffer.alloc(0);
|
|
409
|
+
throw new Error(`Invalid cTrader frame length ${String(length)}`);
|
|
410
|
+
}
|
|
314
411
|
if (this.buffer.length < 4 + length) break;
|
|
315
412
|
frames.push(this.buffer.subarray(4, 4 + length));
|
|
316
413
|
this.buffer = this.buffer.subarray(4 + length);
|
|
@@ -374,9 +471,13 @@ function volumeToLots(volume, lotSize) {
|
|
|
374
471
|
* are in the same centi-unit space. Returns an error string, or null if valid.
|
|
375
472
|
*/
|
|
376
473
|
function validateVolume(volume, minVolume, maxVolume, stepVolume) {
|
|
474
|
+
if (!Number.isSafeInteger(volume) || volume <= 0) return `volume must be a positive safe integer, got ${String(volume)}`;
|
|
475
|
+
if (!Number.isSafeInteger(minVolume) || minVolume <= 0) return `symbol minimum volume is invalid: ${String(minVolume)}`;
|
|
476
|
+
if (!Number.isSafeInteger(maxVolume) || maxVolume < minVolume) return `symbol maximum volume is invalid: ${String(maxVolume)}`;
|
|
477
|
+
if (!Number.isSafeInteger(stepVolume) || stepVolume <= 0) return `symbol volume step is invalid: ${String(stepVolume)}`;
|
|
377
478
|
if (volume < minVolume) return `volume ${String(volume)} is below the symbol minimum ${String(minVolume)}`;
|
|
378
|
-
if (
|
|
379
|
-
if (
|
|
479
|
+
if (volume > maxVolume) return `volume ${String(volume)} exceeds the symbol maximum ${String(maxVolume)}`;
|
|
480
|
+
if ((volume - minVolume) % stepVolume !== 0) return `volume ${String(volume)} does not align to the symbol step ${String(stepVolume)} (offset from min ${String(minVolume)})`;
|
|
380
481
|
return null;
|
|
381
482
|
}
|
|
382
483
|
/** cTrader trendbar/spot integer prices are scaled by 10^5. */
|
|
@@ -494,6 +595,7 @@ const HEARTBEAT_INTERVAL_MS = 1e4;
|
|
|
494
595
|
const REQUEST_TIMEOUT_MS = 2e4;
|
|
495
596
|
const RECONNECT_BASE_MS = 1e3;
|
|
496
597
|
const RECONNECT_MAX_MS = 3e4;
|
|
598
|
+
const MAX_PENDING_REQUESTS = 256;
|
|
497
599
|
/**
|
|
498
600
|
* Read-idle watchdog: cTrader echoes our 10s heartbeats and pushes its own
|
|
499
601
|
* traffic, so a *healthy* socket is never silent for long. If NO inbound byte
|
|
@@ -509,6 +611,10 @@ const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
|
|
|
509
611
|
function log$1(msg) {
|
|
510
612
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
511
613
|
}
|
|
614
|
+
/** Convert a validated decimal account id to an exact protobuf int64 value. */
|
|
615
|
+
function accountIdLong(accountId) {
|
|
616
|
+
return protobuf.util.LongBits.from(accountId).toLong(false);
|
|
617
|
+
}
|
|
512
618
|
/** Production TLS transport. */
|
|
513
619
|
const tlsConnect = (host, port) => new Promise((resolve, reject) => {
|
|
514
620
|
const socket = tls.connect({
|
|
@@ -548,6 +654,7 @@ var HostSocket = class {
|
|
|
548
654
|
reconnectScheduled = false;
|
|
549
655
|
closing = false;
|
|
550
656
|
connectPromise = null;
|
|
657
|
+
reconnectPromise = null;
|
|
551
658
|
/** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
|
|
552
659
|
authedAccounts = /* @__PURE__ */ new Map();
|
|
553
660
|
/** Serializes account-auth so concurrent tool calls don't double-auth. */
|
|
@@ -563,17 +670,44 @@ var HostSocket = class {
|
|
|
563
670
|
}
|
|
564
671
|
/** Connect the socket and run the app-auth handshake. Idempotent. */
|
|
565
672
|
async start() {
|
|
566
|
-
this.
|
|
567
|
-
|
|
673
|
+
if (this.closing) throw new CTraderError("CLIENT_CLOSED", "Client is shutting down");
|
|
674
|
+
if (this.conn) return;
|
|
675
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
676
|
+
if (this.connectPromise) return this.connectPromise;
|
|
677
|
+
const attempt = this.establishConnection();
|
|
678
|
+
this.connectPromise = attempt;
|
|
679
|
+
try {
|
|
680
|
+
await attempt;
|
|
681
|
+
} finally {
|
|
682
|
+
if (this.connectPromise === attempt) this.connectPromise = null;
|
|
683
|
+
}
|
|
568
684
|
}
|
|
569
|
-
async
|
|
570
|
-
|
|
685
|
+
async establishConnection() {
|
|
686
|
+
const conn = await this.connectFn(this.host, CTRADER_PORT);
|
|
687
|
+
if (this.closing) {
|
|
688
|
+
conn.destroy();
|
|
689
|
+
throw new CTraderError("CLIENT_CLOSED", "Client closed while connecting");
|
|
690
|
+
}
|
|
691
|
+
this.parser = new FrameParser();
|
|
692
|
+
this.conn = conn;
|
|
571
693
|
this.lastInboundAt = Date.now();
|
|
572
|
-
this.wireConnection(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
694
|
+
this.wireConnection(conn);
|
|
695
|
+
try {
|
|
696
|
+
await this.appAuth();
|
|
697
|
+
if (this.conn !== conn) throw new CTraderError("CONNECTION_DROPPED", "Socket dropped during application authentication");
|
|
698
|
+
this.startHeartbeat();
|
|
699
|
+
this.startWatchdog();
|
|
700
|
+
log$1(`Connected + app-authenticated to ${this.host}`);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
if (this.conn === conn) {
|
|
703
|
+
this.conn = null;
|
|
704
|
+
this.parser = new FrameParser();
|
|
705
|
+
try {
|
|
706
|
+
conn.destroy();
|
|
707
|
+
} catch {}
|
|
708
|
+
}
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
577
711
|
}
|
|
578
712
|
/**
|
|
579
713
|
* Ensure `accountId` is account-authed on this socket. Deduped: an account is
|
|
@@ -586,7 +720,7 @@ var HostSocket = class {
|
|
|
586
720
|
let inFlight = this.accountAuthPromises.get(accountId);
|
|
587
721
|
if (!inFlight) {
|
|
588
722
|
inFlight = this.request(PayloadType.OA_ACCOUNT_AUTH_REQ, {
|
|
589
|
-
ctidTraderAccountId: accountId,
|
|
723
|
+
ctidTraderAccountId: accountIdLong(accountId),
|
|
590
724
|
accessToken
|
|
591
725
|
}).then(() => {
|
|
592
726
|
this.authedAccounts.set(accountId, accessToken);
|
|
@@ -612,14 +746,24 @@ var HostSocket = class {
|
|
|
612
746
|
}
|
|
613
747
|
wireConnection(conn) {
|
|
614
748
|
conn.on("data", (chunk) => {
|
|
749
|
+
if (this.conn !== conn || this.closing) return;
|
|
615
750
|
this.lastInboundAt = Date.now();
|
|
616
|
-
|
|
751
|
+
try {
|
|
752
|
+
for (const frame of this.parser.push(chunk)) this.dispatch(frame);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
log$1(`Invalid frame stream (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
755
|
+
this.handleDrop(conn);
|
|
756
|
+
try {
|
|
757
|
+
conn.destroy();
|
|
758
|
+
} catch {}
|
|
759
|
+
}
|
|
617
760
|
});
|
|
618
761
|
conn.on("error", (err) => {
|
|
762
|
+
if (this.conn !== conn || this.closing) return;
|
|
619
763
|
log$1(`Socket error (${this.host}): ${err.message}`);
|
|
620
764
|
});
|
|
621
765
|
conn.on("close", () => {
|
|
622
|
-
if (!this.closing) this.handleDrop();
|
|
766
|
+
if (!this.closing) this.handleDrop(conn);
|
|
623
767
|
});
|
|
624
768
|
}
|
|
625
769
|
async appAuth() {
|
|
@@ -680,18 +824,17 @@ var HostSocket = class {
|
|
|
680
824
|
*/
|
|
681
825
|
forceReconnect() {
|
|
682
826
|
const dead = this.conn;
|
|
683
|
-
|
|
827
|
+
if (!dead) return;
|
|
828
|
+
this.handleDrop(dead);
|
|
684
829
|
try {
|
|
685
|
-
dead
|
|
830
|
+
dead.destroy();
|
|
686
831
|
} catch {}
|
|
687
|
-
this.handleDrop();
|
|
688
832
|
}
|
|
689
|
-
handleDrop() {
|
|
690
|
-
if (
|
|
833
|
+
handleDrop(dropped) {
|
|
834
|
+
if (this.conn !== dropped) return;
|
|
691
835
|
log$1(`Socket dropped (${this.host}) — attempting reconnect`);
|
|
692
836
|
this.stopHeartbeat();
|
|
693
837
|
this.stopWatchdog();
|
|
694
|
-
this.reconnectScheduled = true;
|
|
695
838
|
this.conn = null;
|
|
696
839
|
this.parser = new FrameParser();
|
|
697
840
|
this.authedAccounts.clear();
|
|
@@ -700,27 +843,32 @@ var HostSocket = class {
|
|
|
700
843
|
req.reject(new CTraderError("CONNECTION_DROPPED", "Socket closed before a response arrived"));
|
|
701
844
|
this.pending.delete(id);
|
|
702
845
|
}
|
|
703
|
-
this.
|
|
846
|
+
if (!this.reconnectScheduled && !this.closing) {
|
|
847
|
+
this.reconnectScheduled = true;
|
|
848
|
+
const attempt = this.reconnect();
|
|
849
|
+
this.reconnectPromise = attempt;
|
|
850
|
+
attempt.then(() => {
|
|
851
|
+
if (this.reconnectPromise === attempt) this.reconnectPromise = null;
|
|
852
|
+
}, () => {
|
|
853
|
+
if (this.reconnectPromise === attempt) this.reconnectPromise = null;
|
|
854
|
+
});
|
|
855
|
+
}
|
|
704
856
|
}
|
|
705
857
|
async reconnect() {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
log$1(`Reconnected + re-app-authenticated (${this.host})`);
|
|
721
|
-
} catch (err) {
|
|
722
|
-
log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
723
|
-
this.reconnect();
|
|
858
|
+
while (!this.closing) {
|
|
859
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
|
|
860
|
+
this.reconnectAttempts += 1;
|
|
861
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
862
|
+
if (this.closing) return;
|
|
863
|
+
try {
|
|
864
|
+
await this.establishConnection();
|
|
865
|
+
this.reconnectAttempts = 0;
|
|
866
|
+
this.reconnectScheduled = false;
|
|
867
|
+
log$1(`Reconnected + re-app-authenticated (${this.host})`);
|
|
868
|
+
return;
|
|
869
|
+
} catch (err) {
|
|
870
|
+
log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
|
|
871
|
+
}
|
|
724
872
|
}
|
|
725
873
|
}
|
|
726
874
|
dispatch(frame) {
|
|
@@ -754,6 +902,10 @@ var HostSocket = class {
|
|
|
754
902
|
waiter.reject(err);
|
|
755
903
|
return;
|
|
756
904
|
}
|
|
905
|
+
if (decoded.payloadType !== waiter.expectedPayloadType) {
|
|
906
|
+
waiter.reject(new CTraderError("UNEXPECTED_RESPONSE", `Request payloadType ${String(waiter.requestPayloadType)} expected ${String(waiter.expectedPayloadType)} but received ${String(decoded.payloadType)}`));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
757
909
|
waiter.resolve({
|
|
758
910
|
payloadType: decoded.payloadType,
|
|
759
911
|
message: decoded.message
|
|
@@ -783,6 +935,10 @@ var HostSocket = class {
|
|
|
783
935
|
*/
|
|
784
936
|
async request(payloadType, payload) {
|
|
785
937
|
if (!this.conn) throw new CTraderError("NOT_CONNECTED", `The cTrader socket to ${this.host} is not connected`);
|
|
938
|
+
if (this.pending.size >= MAX_PENDING_REQUESTS) throw new CTraderError("TOO_MANY_PENDING_REQUESTS", `The cTrader socket already has ${String(MAX_PENDING_REQUESTS)} requests awaiting replies`);
|
|
939
|
+
const expectedPayloadType = EXPECTED_RESPONSE[payloadType];
|
|
940
|
+
if (expectedPayloadType == null) throw new CTraderError("UNSUPPORTED_REQUEST", `No successful response contract is registered for payloadType ${String(payloadType)}`);
|
|
941
|
+
const conn = this.conn;
|
|
786
942
|
const clientMsgId = randomUUID();
|
|
787
943
|
const frame = encodeRequest(this.root, payloadType, payload, clientMsgId);
|
|
788
944
|
return new Promise((resolve, reject) => {
|
|
@@ -792,11 +948,29 @@ var HostSocket = class {
|
|
|
792
948
|
}, REQUEST_TIMEOUT_MS);
|
|
793
949
|
timer.unref();
|
|
794
950
|
this.pending.set(clientMsgId, {
|
|
951
|
+
requestPayloadType: payloadType,
|
|
952
|
+
expectedPayloadType,
|
|
795
953
|
resolve,
|
|
796
954
|
reject,
|
|
797
955
|
timer
|
|
798
956
|
});
|
|
799
|
-
this.conn
|
|
957
|
+
if (this.conn !== conn) {
|
|
958
|
+
clearTimeout(timer);
|
|
959
|
+
this.pending.delete(clientMsgId);
|
|
960
|
+
reject(new CTraderError("CONNECTION_DROPPED", "Socket changed before the request was written"));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
conn.write(frame);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
clearTimeout(timer);
|
|
967
|
+
this.pending.delete(clientMsgId);
|
|
968
|
+
reject(new CTraderError("REQUEST_WRITE_FAILED", err instanceof Error ? err.message : "The socket rejected the request write"));
|
|
969
|
+
this.handleDrop(conn);
|
|
970
|
+
try {
|
|
971
|
+
conn.destroy();
|
|
972
|
+
} catch {}
|
|
973
|
+
}
|
|
800
974
|
});
|
|
801
975
|
}
|
|
802
976
|
/** Clean shutdown: stop heartbeat, fail waiters, destroy the socket. */
|
|
@@ -811,6 +985,7 @@ var HostSocket = class {
|
|
|
811
985
|
}
|
|
812
986
|
this.conn?.destroy();
|
|
813
987
|
this.conn = null;
|
|
988
|
+
this.connectPromise = null;
|
|
814
989
|
this.authedAccounts.clear();
|
|
815
990
|
this.eventListeners.clear();
|
|
816
991
|
}
|
|
@@ -864,10 +1039,10 @@ var CTraderPool = class {
|
|
|
864
1039
|
* Route a request to a specific account. Resolves the account → its host
|
|
865
1040
|
* socket, ensures the socket is connected + app-authed and the account is
|
|
866
1041
|
* account-authed, then sends the request. Every account-scoped request must
|
|
867
|
-
* go through here so it lands on the RIGHT host socket. The
|
|
868
|
-
* `ctidTraderAccountId` is
|
|
869
|
-
*
|
|
870
|
-
*
|
|
1042
|
+
* go through here so it lands on the RIGHT host socket. The routed registry
|
|
1043
|
+
* entry is authoritative: its `ctidTraderAccountId` is injected after the
|
|
1044
|
+
* caller payload, so host/auth selection and protobuf account identity can
|
|
1045
|
+
* never diverge.
|
|
871
1046
|
*
|
|
872
1047
|
* Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
|
|
873
1048
|
* (fail closed — never fall back to another account).
|
|
@@ -877,7 +1052,10 @@ var CTraderPool = class {
|
|
|
877
1052
|
if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
|
|
878
1053
|
const socket = this.socketFor(config);
|
|
879
1054
|
await socket.authenticateAccount(config.accountId, config.accessToken);
|
|
880
|
-
return socket.request(payloadType,
|
|
1055
|
+
return socket.request(payloadType, {
|
|
1056
|
+
...payload,
|
|
1057
|
+
ctidTraderAccountId: accountIdLong(config.accountId)
|
|
1058
|
+
});
|
|
881
1059
|
}
|
|
882
1060
|
/**
|
|
883
1061
|
* Register an unsolicited-event listener on the socket that serves
|
|
@@ -988,13 +1166,32 @@ const EXECUTION_TYPE_NAME = {
|
|
|
988
1166
|
11: "ORDER_PARTIAL_FILL",
|
|
989
1167
|
12: "BONUS_DEPOSIT_WITHDRAW"
|
|
990
1168
|
};
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
7,
|
|
996
|
-
8
|
|
1169
|
+
const PLACE_ORDER_SUCCESS_TYPES = new Set([
|
|
1170
|
+
2,
|
|
1171
|
+
3,
|
|
1172
|
+
11
|
|
997
1173
|
]);
|
|
1174
|
+
const AMEND_SUCCESS_TYPES = new Set([4]);
|
|
1175
|
+
const CLOSE_SUCCESS_TYPES = new Set([3, 11]);
|
|
1176
|
+
const CANCEL_SUCCESS_TYPES = new Set([5]);
|
|
1177
|
+
const WRITE_TOOL_NAMES = new Set([
|
|
1178
|
+
"place_order",
|
|
1179
|
+
"modify_order",
|
|
1180
|
+
"close_position",
|
|
1181
|
+
"cancel_order"
|
|
1182
|
+
]);
|
|
1183
|
+
const READ_ANNOTATIONS = {
|
|
1184
|
+
readOnlyHint: true,
|
|
1185
|
+
destructiveHint: false,
|
|
1186
|
+
idempotentHint: true,
|
|
1187
|
+
openWorldHint: true
|
|
1188
|
+
};
|
|
1189
|
+
const WRITE_ANNOTATIONS = {
|
|
1190
|
+
readOnlyHint: false,
|
|
1191
|
+
destructiveHint: true,
|
|
1192
|
+
idempotentHint: false,
|
|
1193
|
+
openWorldHint: true
|
|
1194
|
+
};
|
|
998
1195
|
const TRENDBAR_PERIOD = {
|
|
999
1196
|
M1: 1,
|
|
1000
1197
|
M2: 2,
|
|
@@ -1037,7 +1234,7 @@ function num(v) {
|
|
|
1037
1234
|
/** Serialize an account for a listing (both the registry and error payloads). */
|
|
1038
1235
|
function describeAccount(config) {
|
|
1039
1236
|
return {
|
|
1040
|
-
accountId:
|
|
1237
|
+
accountId: config.accountId,
|
|
1041
1238
|
isLive: config.isLive,
|
|
1042
1239
|
host: config.host,
|
|
1043
1240
|
broker: config.brokerName ?? null,
|
|
@@ -1094,12 +1291,12 @@ function isToolError(v) {
|
|
|
1094
1291
|
return "content" in v;
|
|
1095
1292
|
}
|
|
1096
1293
|
/** Fetch a symbol's full detail (for lotSize / volume rules / digits). */
|
|
1097
|
-
async function getSymbolDetail(pool, accountId,
|
|
1294
|
+
async function getSymbolDetail(pool, accountId, ctidTraderAccountId, symbolId) {
|
|
1098
1295
|
const symbols = (await pool.request(accountId, PayloadType.OA_SYMBOL_BY_ID_REQ, {
|
|
1099
|
-
ctidTraderAccountId
|
|
1296
|
+
ctidTraderAccountId,
|
|
1100
1297
|
symbolId: [symbolId]
|
|
1101
1298
|
})).message.symbol ?? [];
|
|
1102
|
-
if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${
|
|
1299
|
+
if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${symbolId} on this account`);
|
|
1103
1300
|
return symbols[0];
|
|
1104
1301
|
}
|
|
1105
1302
|
/** Unref'd sleep so a pending collection window never keeps the process alive. */
|
|
@@ -1146,25 +1343,42 @@ const LIVE_SUBSCRIPTION = {
|
|
|
1146
1343
|
event: PayloadType.OA_SPOT_EVENT
|
|
1147
1344
|
}
|
|
1148
1345
|
};
|
|
1346
|
+
const MAX_SIGNED_INT64 = 9223372036854775807n;
|
|
1347
|
+
function isCanonicalInt64(value) {
|
|
1348
|
+
if (!/^[1-9][0-9]*$/.test(value)) return false;
|
|
1349
|
+
try {
|
|
1350
|
+
return BigInt(value) <= MAX_SIGNED_INT64;
|
|
1351
|
+
} catch {
|
|
1352
|
+
return false;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* cTrader identifiers are int64 values and are returned to tools as decimal
|
|
1357
|
+
* strings. Accept safe JSON numbers for compatibility, but never round an
|
|
1358
|
+
* unsafe number or exponent/decimal string through JavaScript Number.
|
|
1359
|
+
*/
|
|
1360
|
+
function int64IdField(description) {
|
|
1361
|
+
return z.union([z.string(), z.number().int().positive()]).transform((value) => String(value)).refine(isCanonicalInt64, "Expected a canonical positive signed-int64 decimal string").describe(description);
|
|
1362
|
+
}
|
|
1149
1363
|
/**
|
|
1150
1364
|
* Subscribe → run `collect` while `handler` receives this symbol's events →
|
|
1151
1365
|
* ALWAYS unsubscribe + unregister (finally). The unsubscribe is best-effort:
|
|
1152
1366
|
* its failure is logged, never masks the result, and a truly leaked
|
|
1153
1367
|
* subscription dies with the socket.
|
|
1154
1368
|
*/
|
|
1155
|
-
async function withLiveSubscription(pool,
|
|
1156
|
-
const accountId =
|
|
1369
|
+
async function withLiveSubscription(pool, ctidTraderAccountId, symbolId, kind, handler, collect) {
|
|
1370
|
+
const accountId = ctidTraderAccountId;
|
|
1157
1371
|
const sub = LIVE_SUBSCRIPTION[kind];
|
|
1158
|
-
return withLiveDataLock(`${accountId}:${
|
|
1372
|
+
return withLiveDataLock(`${accountId}:${symbolId}:${kind}`, async () => {
|
|
1159
1373
|
const unregister = pool.onAccountEvent(accountId, (event) => {
|
|
1160
1374
|
if (event.payloadType !== sub.event) return;
|
|
1161
1375
|
if (str(event.message.ctidTraderAccountId) !== accountId) return;
|
|
1162
|
-
if (str(event.message.symbolId) !==
|
|
1376
|
+
if (str(event.message.symbolId) !== symbolId) return;
|
|
1163
1377
|
handler(event.message);
|
|
1164
1378
|
});
|
|
1165
1379
|
try {
|
|
1166
1380
|
await pool.request(accountId, sub.subscribe, {
|
|
1167
|
-
ctidTraderAccountId
|
|
1381
|
+
ctidTraderAccountId,
|
|
1168
1382
|
symbolId: [symbolId]
|
|
1169
1383
|
});
|
|
1170
1384
|
return await collect();
|
|
@@ -1172,11 +1386,11 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
|
|
|
1172
1386
|
unregister();
|
|
1173
1387
|
try {
|
|
1174
1388
|
await pool.request(accountId, sub.unsubscribe, {
|
|
1175
|
-
ctidTraderAccountId
|
|
1389
|
+
ctidTraderAccountId,
|
|
1176
1390
|
symbolId: [symbolId]
|
|
1177
1391
|
});
|
|
1178
1392
|
} catch (err) {
|
|
1179
|
-
process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${
|
|
1393
|
+
process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${symbolId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1180
1394
|
}
|
|
1181
1395
|
}
|
|
1182
1396
|
});
|
|
@@ -1187,9 +1401,13 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
|
|
|
1187
1401
|
* required-when-multiple rule at runtime so the model gets a helpful listing
|
|
1188
1402
|
* instead of a bare validation error.
|
|
1189
1403
|
*/
|
|
1190
|
-
const accountIdField =
|
|
1404
|
+
const accountIdField = int64IdField("ctidTraderAccountId of the account to act on (from get_accounts). Optional when exactly one account is connected; REQUIRED when several are — this tool never defaults to an arbitrary account.").optional();
|
|
1191
1405
|
function registerTools(server, pool) {
|
|
1192
|
-
const
|
|
1406
|
+
const rawRegister = server.registerTool.bind(server);
|
|
1407
|
+
const register = (name, definition, handler) => rawRegister(name, {
|
|
1408
|
+
...definition,
|
|
1409
|
+
annotations: WRITE_TOOL_NAMES.has(name) ? WRITE_ANNOTATIONS : READ_ANNOTATIONS
|
|
1410
|
+
}, handler);
|
|
1193
1411
|
register("get_accounts", {
|
|
1194
1412
|
description: "List the cTrader trading accounts connected for this agent. Returns each account's ctidTraderAccountId, live/demo flag, host, broker, and number. Use an accountId here as the `accountId` argument on the other tools.",
|
|
1195
1413
|
inputSchema: {}
|
|
@@ -1207,10 +1425,10 @@ function registerTools(server, pool) {
|
|
|
1207
1425
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1208
1426
|
if (isToolError(resolved)) return resolved;
|
|
1209
1427
|
try {
|
|
1210
|
-
const trader = (await pool.request(
|
|
1428
|
+
const trader = (await pool.request(resolved.accountId, PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
|
|
1211
1429
|
const moneyDigits = trader.moneyDigits != null ? num(trader.moneyDigits) : 2;
|
|
1212
1430
|
return ok({
|
|
1213
|
-
ctidTraderAccountId: str(trader.ctidTraderAccountId) ||
|
|
1431
|
+
ctidTraderAccountId: str(trader.ctidTraderAccountId) || resolved.accountId,
|
|
1214
1432
|
balance: moneyToDecimal(num(trader.balance), moneyDigits),
|
|
1215
1433
|
balanceRaw: str(trader.balance) || "0",
|
|
1216
1434
|
moneyDigits,
|
|
@@ -1230,12 +1448,12 @@ function registerTools(server, pool) {
|
|
|
1230
1448
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1231
1449
|
if (isToolError(resolved)) return resolved;
|
|
1232
1450
|
try {
|
|
1233
|
-
const positions = (await pool.request(
|
|
1451
|
+
const positions = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1234
1452
|
ctidTraderAccountId: resolved.accountId,
|
|
1235
1453
|
returnProtectionOrders: true
|
|
1236
1454
|
})).message.position ?? [];
|
|
1237
1455
|
return ok({
|
|
1238
|
-
accountId:
|
|
1456
|
+
accountId: resolved.accountId,
|
|
1239
1457
|
positions: positions.map((p) => {
|
|
1240
1458
|
const td = p.tradeData ?? {};
|
|
1241
1459
|
return {
|
|
@@ -1261,13 +1479,13 @@ function registerTools(server, pool) {
|
|
|
1261
1479
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1262
1480
|
if (isToolError(resolved)) return resolved;
|
|
1263
1481
|
try {
|
|
1264
|
-
const orders = (await pool.request(
|
|
1482
|
+
const orders = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1265
1483
|
ctidTraderAccountId: resolved.accountId,
|
|
1266
1484
|
returnProtectionOrders: true
|
|
1267
1485
|
})).message.order ?? [];
|
|
1268
1486
|
const typeName = (v) => v === ORDER_TYPE.LIMIT ? "LIMIT" : v === ORDER_TYPE.STOP ? "STOP" : v === ORDER_TYPE.STOP_LIMIT ? "STOP_LIMIT" : v === ORDER_TYPE.MARKET ? "MARKET" : String(v);
|
|
1269
1487
|
return ok({
|
|
1270
|
-
accountId:
|
|
1488
|
+
accountId: resolved.accountId,
|
|
1271
1489
|
orders: orders.map((o) => {
|
|
1272
1490
|
const td = o.tradeData ?? {};
|
|
1273
1491
|
return {
|
|
@@ -1291,13 +1509,13 @@ function registerTools(server, pool) {
|
|
|
1291
1509
|
description: "List tradable symbols on a connected cTrader account (symbol lists are per-account). Optionally filter by a name substring (case-insensitive, e.g. \"EURUSD\"). Returns symbolId + name — use symbolId on order and market-data tools. Pass `accountId` when several accounts are connected.",
|
|
1292
1510
|
inputSchema: {
|
|
1293
1511
|
accountId: accountIdField,
|
|
1294
|
-
nameFilter: z.string().optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
|
|
1512
|
+
nameFilter: z.string().max(100).optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
|
|
1295
1513
|
}
|
|
1296
1514
|
}, async (args) => {
|
|
1297
1515
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1298
1516
|
if (isToolError(resolved)) return resolved;
|
|
1299
1517
|
try {
|
|
1300
|
-
let symbols = (await pool.request(
|
|
1518
|
+
let symbols = (await pool.request(resolved.accountId, PayloadType.OA_SYMBOLS_LIST_REQ, {
|
|
1301
1519
|
ctidTraderAccountId: resolved.accountId,
|
|
1302
1520
|
includeArchivedSymbols: false
|
|
1303
1521
|
})).message.symbol ?? [];
|
|
@@ -1306,7 +1524,7 @@ function registerTools(server, pool) {
|
|
|
1306
1524
|
symbols = symbols.filter((s) => str(s.symbolName).toLowerCase().includes(needle));
|
|
1307
1525
|
}
|
|
1308
1526
|
return ok({
|
|
1309
|
-
accountId:
|
|
1527
|
+
accountId: resolved.accountId,
|
|
1310
1528
|
count: symbols.length,
|
|
1311
1529
|
symbols: symbols.map((s) => ({
|
|
1312
1530
|
symbolId: str(s.symbolId),
|
|
@@ -1323,7 +1541,7 @@ function registerTools(server, pool) {
|
|
|
1323
1541
|
description: "Get recent OHLC candles (trendbars) for a symbol on a connected cTrader account. Specify symbolId (from get_symbols), a period, and how many bars. Prices are returned as real decimal prices. Pass `accountId` when several accounts are connected.",
|
|
1324
1542
|
inputSchema: {
|
|
1325
1543
|
accountId: accountIdField,
|
|
1326
|
-
symbolId:
|
|
1544
|
+
symbolId: int64IdField("The symbol id from get_symbols"),
|
|
1327
1545
|
period: z.enum(Object.keys(TRENDBAR_PERIOD)).default("H1").describe("Candle period: M1, M5, M15, M30, H1, H4, D1, W1, MN1, etc."),
|
|
1328
1546
|
count: z.coerce.number().int().min(1).max(1e3).default(50).describe("Number of most-recent bars to return (1-1000)")
|
|
1329
1547
|
}
|
|
@@ -1332,15 +1550,15 @@ function registerTools(server, pool) {
|
|
|
1332
1550
|
if (isToolError(resolved)) return resolved;
|
|
1333
1551
|
try {
|
|
1334
1552
|
const period = TRENDBAR_PERIOD[args.period];
|
|
1335
|
-
const bars = (await pool.request(
|
|
1553
|
+
const bars = (await pool.request(resolved.accountId, PayloadType.OA_GET_TRENDBARS_REQ, {
|
|
1336
1554
|
ctidTraderAccountId: resolved.accountId,
|
|
1337
1555
|
symbolId: args.symbolId,
|
|
1338
1556
|
period,
|
|
1339
1557
|
count: args.count
|
|
1340
1558
|
})).message.trendbar ?? [];
|
|
1341
1559
|
return ok({
|
|
1342
|
-
accountId:
|
|
1343
|
-
symbolId:
|
|
1560
|
+
accountId: resolved.accountId,
|
|
1561
|
+
symbolId: args.symbolId,
|
|
1344
1562
|
period: args.period,
|
|
1345
1563
|
bars: bars.map((b) => decodeTrendbar(b))
|
|
1346
1564
|
});
|
|
@@ -1352,19 +1570,19 @@ function registerTools(server, pool) {
|
|
|
1352
1570
|
description: "Get full trading details for one symbol on a connected cTrader account: price digits, pip position, lot size, min/max/step volume (in lots), swap rates, and short-selling availability. Pass `accountId` when several accounts are connected.",
|
|
1353
1571
|
inputSchema: {
|
|
1354
1572
|
accountId: accountIdField,
|
|
1355
|
-
symbolId:
|
|
1573
|
+
symbolId: int64IdField("Symbol id from get_symbols")
|
|
1356
1574
|
}
|
|
1357
1575
|
}, async (args) => {
|
|
1358
1576
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1359
1577
|
if (isToolError(resolved)) return resolved;
|
|
1360
|
-
const accountId =
|
|
1578
|
+
const accountId = resolved.accountId;
|
|
1361
1579
|
try {
|
|
1362
1580
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1363
1581
|
const lotSize = num(symbol.lotSize);
|
|
1364
1582
|
const toLots = (v) => lotSize > 0 ? volumeToLots(num(v), lotSize) : null;
|
|
1365
1583
|
return ok({
|
|
1366
1584
|
accountId,
|
|
1367
|
-
symbolId: str(symbol.symbolId) ||
|
|
1585
|
+
symbolId: str(symbol.symbolId) || args.symbolId,
|
|
1368
1586
|
digits: num(symbol.digits),
|
|
1369
1587
|
pipPosition: num(symbol.pipPosition),
|
|
1370
1588
|
lotSize,
|
|
@@ -1385,13 +1603,13 @@ function registerTools(server, pool) {
|
|
|
1385
1603
|
description: "Get the LIVE bid/ask for a symbol on a connected cTrader account via a brief spot subscription (ticks merge until both sides are seen, up to waitMs). Returns bid, ask, and spread as real decimal prices. Pass `accountId` when several accounts are connected.",
|
|
1386
1604
|
inputSchema: {
|
|
1387
1605
|
accountId: accountIdField,
|
|
1388
|
-
symbolId:
|
|
1606
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1389
1607
|
waitMs: z.coerce.number().int().min(200).max(1e4).default(3e3).describe("Max milliseconds to wait for both bid and ask ticks (returns early once both are seen)")
|
|
1390
1608
|
}
|
|
1391
1609
|
}, async (args) => {
|
|
1392
1610
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1393
1611
|
if (isToolError(resolved)) return resolved;
|
|
1394
|
-
const accountId =
|
|
1612
|
+
const accountId = resolved.accountId;
|
|
1395
1613
|
try {
|
|
1396
1614
|
const digits = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).digits);
|
|
1397
1615
|
const quote = {
|
|
@@ -1409,10 +1627,10 @@ function registerTools(server, pool) {
|
|
|
1409
1627
|
if (message.ask != null) quote.ask = roundToDigits(priceToDecimal(num(message.ask)), digits);
|
|
1410
1628
|
if (quote.bid != null && quote.ask != null) signalBothSeen();
|
|
1411
1629
|
}, () => Promise.race([bothSeen, sleep(args.waitMs)]));
|
|
1412
|
-
if (quote.bid == null && quote.ask == null) return fail(new CTraderError("NO_QUOTE_DATA", `No live ticks for symbol ${
|
|
1630
|
+
if (quote.bid == null && quote.ask == null) return fail(new CTraderError("NO_QUOTE_DATA", `No live ticks for symbol ${args.symbolId} within ${String(args.waitMs)}ms — the market may be closed or the symbol not quoted on this account`));
|
|
1413
1631
|
return ok({
|
|
1414
1632
|
accountId,
|
|
1415
|
-
symbolId:
|
|
1633
|
+
symbolId: args.symbolId,
|
|
1416
1634
|
digits,
|
|
1417
1635
|
bid: quote.bid,
|
|
1418
1636
|
ask: quote.ask,
|
|
@@ -1428,14 +1646,14 @@ function registerTools(server, pool) {
|
|
|
1428
1646
|
description: "Get the LIVE Level 2 order book (depth of market) for a symbol on a connected cTrader account. Subscribes briefly, assembles the bid/ask ladder (sizes aggregated per price level), then unsubscribes. Returns bids (descending) and asks (ascending) with sizes in base-asset units and lots, plus best bid/ask and spread. Not every broker/symbol publishes depth — a NO_DEPTH_DATA error means none arrived. Pass `accountId` when several accounts are connected.",
|
|
1429
1647
|
inputSchema: {
|
|
1430
1648
|
accountId: accountIdField,
|
|
1431
|
-
symbolId:
|
|
1649
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1432
1650
|
levels: z.coerce.number().int().min(1).max(50).default(10).describe("Max price levels per side to return"),
|
|
1433
1651
|
collectMs: z.coerce.number().int().min(200).max(5e3).default(1e3).describe("How long to collect depth events before snapshotting (the full book arrives on subscribe; longer windows fold in more updates)")
|
|
1434
1652
|
}
|
|
1435
1653
|
}, async (args) => {
|
|
1436
1654
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1437
1655
|
if (isToolError(resolved)) return resolved;
|
|
1438
|
-
const accountId =
|
|
1656
|
+
const accountId = resolved.accountId;
|
|
1439
1657
|
try {
|
|
1440
1658
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1441
1659
|
const digits = num(symbol.digits);
|
|
@@ -1461,7 +1679,7 @@ function registerTools(server, pool) {
|
|
|
1461
1679
|
}
|
|
1462
1680
|
for (const deleted of message.deletedQuotes ?? []) book.delete(str(deleted));
|
|
1463
1681
|
}, () => sleep(args.collectMs));
|
|
1464
|
-
if (eventsReceived === 0) return fail(new CTraderError("NO_DEPTH_DATA", `No depth events for symbol ${
|
|
1682
|
+
if (eventsReceived === 0) return fail(new CTraderError("NO_DEPTH_DATA", `No depth events for symbol ${args.symbolId} within ${String(args.collectMs)}ms — this broker/symbol may not publish Level 2 via the Open API, or the market is closed`));
|
|
1465
1683
|
const ladder = (side) => {
|
|
1466
1684
|
const byPrice = /* @__PURE__ */ new Map();
|
|
1467
1685
|
for (const quote of book.values()) {
|
|
@@ -1482,7 +1700,7 @@ function registerTools(server, pool) {
|
|
|
1482
1700
|
const bestAsk = asks.at(0)?.price ?? null;
|
|
1483
1701
|
return ok({
|
|
1484
1702
|
accountId,
|
|
1485
|
-
symbolId:
|
|
1703
|
+
symbolId: args.symbolId,
|
|
1486
1704
|
digits,
|
|
1487
1705
|
bestBid,
|
|
1488
1706
|
bestAsk,
|
|
@@ -1503,15 +1721,15 @@ function registerTools(server, pool) {
|
|
|
1503
1721
|
description: "Get historical tick-by-tick prices (BID or ASK side) for a symbol on a connected cTrader account. Defaults to the last 5 minutes — keep ranges short, tick volumes are large; `hasMore` signals truncation. Ticks are returned newest-first with real decimal prices.",
|
|
1504
1722
|
inputSchema: {
|
|
1505
1723
|
accountId: accountIdField,
|
|
1506
|
-
symbolId:
|
|
1724
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1507
1725
|
type: z.enum(["BID", "ASK"]).default("BID").describe("Which side's ticks to fetch"),
|
|
1508
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
|
|
1509
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)")
|
|
1726
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
|
|
1727
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
|
|
1510
1728
|
}
|
|
1511
1729
|
}, async (args) => {
|
|
1512
1730
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1513
1731
|
if (isToolError(resolved)) return resolved;
|
|
1514
|
-
const accountId =
|
|
1732
|
+
const accountId = resolved.accountId;
|
|
1515
1733
|
try {
|
|
1516
1734
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1517
1735
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 5 * 6e4;
|
|
@@ -1526,7 +1744,7 @@ function registerTools(server, pool) {
|
|
|
1526
1744
|
const raw = res.message.tickData ?? [];
|
|
1527
1745
|
return ok({
|
|
1528
1746
|
accountId,
|
|
1529
|
-
symbolId:
|
|
1747
|
+
symbolId: args.symbolId,
|
|
1530
1748
|
type: args.type,
|
|
1531
1749
|
count: raw.length,
|
|
1532
1750
|
hasMore: Boolean(res.message.hasMore),
|
|
@@ -1540,14 +1758,14 @@ function registerTools(server, pool) {
|
|
|
1540
1758
|
description: "Get executed deal (fill) history for a connected cTrader account: entry/exit fills with price, volume, commission, and — for closing deals — realized PnL. Defaults to the last 7 days; cTrader caps the from/to span (about a week per request).",
|
|
1541
1759
|
inputSchema: {
|
|
1542
1760
|
accountId: accountIdField,
|
|
1543
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
|
|
1544
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)"),
|
|
1761
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
|
|
1762
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)"),
|
|
1545
1763
|
maxRows: z.coerce.number().int().min(1).max(1e3).default(100).describe("Max deals to return")
|
|
1546
1764
|
}
|
|
1547
1765
|
}, async (args) => {
|
|
1548
1766
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1549
1767
|
if (isToolError(resolved)) return resolved;
|
|
1550
|
-
const accountId =
|
|
1768
|
+
const accountId = resolved.accountId;
|
|
1551
1769
|
try {
|
|
1552
1770
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1553
1771
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 7 * 864e5;
|
|
@@ -1599,7 +1817,7 @@ function registerTools(server, pool) {
|
|
|
1599
1817
|
}, async (args) => {
|
|
1600
1818
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1601
1819
|
if (isToolError(resolved)) return resolved;
|
|
1602
|
-
const accountId =
|
|
1820
|
+
const accountId = resolved.accountId;
|
|
1603
1821
|
try {
|
|
1604
1822
|
const res = await pool.request(accountId, PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ, { ctidTraderAccountId: resolved.accountId });
|
|
1605
1823
|
const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
|
|
@@ -1620,16 +1838,16 @@ function registerTools(server, pool) {
|
|
|
1620
1838
|
description: "Get the margin that would be required to open BUY/SELL positions of given sizes (in lots) on a symbol, in the account's deposit currency. Useful before place_order to check affordability. Pass `accountId` when several accounts are connected.",
|
|
1621
1839
|
inputSchema: {
|
|
1622
1840
|
accountId: accountIdField,
|
|
1623
|
-
symbolId:
|
|
1841
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1624
1842
|
volumesLots: z.array(z.coerce.number().positive()).min(1).max(10).describe("Position sizes in lots to quote margin for, e.g. [0.1, 0.5, 1]")
|
|
1625
1843
|
}
|
|
1626
1844
|
}, async (args) => {
|
|
1627
1845
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1628
1846
|
if (isToolError(resolved)) return resolved;
|
|
1629
|
-
const accountId =
|
|
1847
|
+
const accountId = resolved.accountId;
|
|
1630
1848
|
try {
|
|
1631
1849
|
const lotSize = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).lotSize);
|
|
1632
|
-
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${
|
|
1850
|
+
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the margin quote`));
|
|
1633
1851
|
const res = await pool.request(accountId, PayloadType.OA_EXPECTED_MARGIN_REQ, {
|
|
1634
1852
|
ctidTraderAccountId: resolved.accountId,
|
|
1635
1853
|
symbolId: args.symbolId,
|
|
@@ -1639,7 +1857,7 @@ function registerTools(server, pool) {
|
|
|
1639
1857
|
const margins = res.message.margin ?? [];
|
|
1640
1858
|
return ok({
|
|
1641
1859
|
accountId,
|
|
1642
|
-
symbolId:
|
|
1860
|
+
symbolId: args.symbolId,
|
|
1643
1861
|
margins: margins.map((m) => ({
|
|
1644
1862
|
volumeLots: volumeToLots(num(m.volume), lotSize),
|
|
1645
1863
|
buyMargin: moneyToDecimal(num(m.buyMargin), moneyDigits),
|
|
@@ -1654,13 +1872,13 @@ function registerTools(server, pool) {
|
|
|
1654
1872
|
description: "Get deposit/withdrawal history for a connected cTrader account over a date range (default: last 30 days). Amounts are real currency figures; positive delta = deposit, negative = withdrawal.",
|
|
1655
1873
|
inputSchema: {
|
|
1656
1874
|
accountId: accountIdField,
|
|
1657
|
-
from: z.string().optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
|
|
1658
|
-
to: z.string().optional().describe("ISO 8601 range end (default: now)")
|
|
1875
|
+
from: z.string().max(100).optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
|
|
1876
|
+
to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
|
|
1659
1877
|
}
|
|
1660
1878
|
}, async (args) => {
|
|
1661
1879
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1662
1880
|
if (isToolError(resolved)) return resolved;
|
|
1663
|
-
const accountId =
|
|
1881
|
+
const accountId = resolved.accountId;
|
|
1664
1882
|
try {
|
|
1665
1883
|
const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
|
|
1666
1884
|
const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 30 * 864e5;
|
|
@@ -1692,7 +1910,7 @@ function registerTools(server, pool) {
|
|
|
1692
1910
|
description: "Place a MARKET, LIMIT, STOP, or STOP_LIMIT order on a connected cTrader account. Volume is in LOTS and is converted to the symbol's protocol volume (validated against min/max/step). LIMIT requires limitPrice; STOP requires stopPrice; STOP_LIMIT requires stopPrice + slippageInPoints. SL/TP: pass ABSOLUTE prices via stopLoss/takeProfit, or price DISTANCES via stopLossDistance/takeProfitDistance (required for a trailing stop). timeInForce defaults to GOOD_TILL_CANCEL; GOOD_TILL_DATE requires expiresAt. Pass `accountId` when several accounts are connected. WARNING: on a live account this moves real money.",
|
|
1693
1911
|
inputSchema: {
|
|
1694
1912
|
accountId: accountIdField,
|
|
1695
|
-
symbolId:
|
|
1913
|
+
symbolId: int64IdField("Symbol id from get_symbols"),
|
|
1696
1914
|
side: z.enum(["BUY", "SELL"]).describe("Trade side"),
|
|
1697
1915
|
orderType: z.enum([
|
|
1698
1916
|
"MARKET",
|
|
@@ -1715,17 +1933,19 @@ function registerTools(server, pool) {
|
|
|
1715
1933
|
"IMMEDIATE_OR_CANCEL",
|
|
1716
1934
|
"FILL_OR_KILL"
|
|
1717
1935
|
]).optional().describe("Order lifetime (default GOOD_TILL_CANCEL). GOOD_TILL_DATE requires expiresAt."),
|
|
1718
|
-
expiresAt: z.string().optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
|
|
1719
|
-
label: z.string().optional().describe("Optional client label for the order")
|
|
1936
|
+
expiresAt: z.string().max(100).optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
|
|
1937
|
+
label: z.string().max(50).optional().describe("Optional client label for the order (max 50 characters)")
|
|
1720
1938
|
}
|
|
1721
1939
|
}, async (args) => {
|
|
1722
1940
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1723
1941
|
if (isToolError(resolved)) return resolved;
|
|
1724
|
-
const accountId =
|
|
1942
|
+
const accountId = resolved.accountId;
|
|
1725
1943
|
try {
|
|
1726
1944
|
if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
|
|
1727
1945
|
if ((args.orderType === "STOP" || args.orderType === "STOP_LIMIT") && args.stopPrice == null) return fail(new CTraderError("STOP_PRICE_REQUIRED", `${args.orderType} orders require stopPrice`));
|
|
1728
1946
|
if (args.orderType === "STOP_LIMIT" && args.slippageInPoints == null) return fail(new CTraderError("SLIPPAGE_REQUIRED", "STOP_LIMIT orders require slippageInPoints"));
|
|
1947
|
+
if (args.orderType === "STOP_LIMIT" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP_LIMIT orders use stopPrice + slippageInPoints, not limitPrice"));
|
|
1948
|
+
if (args.orderType !== "STOP_LIMIT" && args.slippageInPoints != null) return fail(new CTraderError("SLIPPAGE_NOT_ALLOWED", "slippageInPoints is only valid for STOP_LIMIT orders"));
|
|
1729
1949
|
if (args.orderType === "MARKET" && (args.limitPrice != null || args.stopPrice != null)) return fail(new CTraderError("PRICE_NOT_ALLOWED", "MARKET orders take no limitPrice/stopPrice — use LIMIT or STOP"));
|
|
1730
1950
|
if (args.orderType === "LIMIT" && args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "LIMIT orders take no stopPrice"));
|
|
1731
1951
|
if (args.orderType === "STOP" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP orders take no limitPrice"));
|
|
@@ -1737,9 +1957,18 @@ function registerTools(server, pool) {
|
|
|
1737
1957
|
}
|
|
1738
1958
|
if (args.timeInForce === "GOOD_TILL_DATE" && args.expiresAt == null) return fail(new CTraderError("EXPIRATION_REQUIRED", "timeInForce GOOD_TILL_DATE requires expiresAt"));
|
|
1739
1959
|
if (args.expiresAt != null && args.timeInForce !== "GOOD_TILL_DATE") return fail(new CTraderError("EXPIRATION_NOT_ALLOWED", "expiresAt is only valid with timeInForce GOOD_TILL_DATE"));
|
|
1960
|
+
let expirationTimestamp;
|
|
1961
|
+
if (args.expiresAt != null) {
|
|
1962
|
+
expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
|
|
1963
|
+
if (expirationTimestamp <= Date.now()) return fail(new CTraderError("EXPIRATION_IN_PAST", "expiresAt must be in the future"));
|
|
1964
|
+
}
|
|
1965
|
+
const relativeStopLoss = args.stopLossDistance != null ? Math.round(args.stopLossDistance * PRICE_SCALE) : void 0;
|
|
1966
|
+
const relativeTakeProfit = args.takeProfitDistance != null ? Math.round(args.takeProfitDistance * PRICE_SCALE) : void 0;
|
|
1967
|
+
if (relativeStopLoss != null && (!Number.isSafeInteger(relativeStopLoss) || relativeStopLoss <= 0)) return fail(new CTraderError("INVALID_STOP_LOSS_DISTANCE", "stopLossDistance is too small or too large for cTrader's relative-price units"));
|
|
1968
|
+
if (relativeTakeProfit != null && (!Number.isSafeInteger(relativeTakeProfit) || relativeTakeProfit <= 0)) return fail(new CTraderError("INVALID_TAKE_PROFIT_DISTANCE", "takeProfitDistance is too small or too large for cTrader's relative-price units"));
|
|
1740
1969
|
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
|
|
1741
1970
|
const lotSize = num(symbol.lotSize);
|
|
1742
|
-
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${
|
|
1971
|
+
if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the order`));
|
|
1743
1972
|
const volume = lotsToVolume(args.volumeLots, lotSize);
|
|
1744
1973
|
const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
|
|
1745
1974
|
if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
|
|
@@ -1755,13 +1984,13 @@ function registerTools(server, pool) {
|
|
|
1755
1984
|
if (args.slippageInPoints != null) payload.slippageInPoints = args.slippageInPoints;
|
|
1756
1985
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1757
1986
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1758
|
-
if (
|
|
1759
|
-
if (
|
|
1987
|
+
if (relativeStopLoss != null) payload.relativeStopLoss = relativeStopLoss;
|
|
1988
|
+
if (relativeTakeProfit != null) payload.relativeTakeProfit = relativeTakeProfit;
|
|
1760
1989
|
if (args.trailingStopLoss != null) payload.trailingStopLoss = args.trailingStopLoss;
|
|
1761
1990
|
if (args.timeInForce != null) payload.timeInForce = TIME_IN_FORCE[args.timeInForce];
|
|
1762
|
-
if (
|
|
1991
|
+
if (expirationTimestamp != null) payload.expirationTimestamp = expirationTimestamp;
|
|
1763
1992
|
if (args.label != null) payload.label = args.label;
|
|
1764
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
|
|
1993
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload), PLACE_ORDER_SUCCESS_TYPES);
|
|
1765
1994
|
return ok({
|
|
1766
1995
|
accountId,
|
|
1767
1996
|
executionType,
|
|
@@ -1777,8 +2006,8 @@ function registerTools(server, pool) {
|
|
|
1777
2006
|
description: "Modify an existing order on a connected cTrader account. For a PENDING order (by orderId) you can change limitPrice/stopPrice/SL/TP. For an OPEN position's protection, pass positionId to set stopLoss/takeProfit. Provide exactly one of orderId or positionId. Pass `accountId` when several accounts are connected.",
|
|
1778
2007
|
inputSchema: {
|
|
1779
2008
|
accountId: accountIdField,
|
|
1780
|
-
orderId:
|
|
1781
|
-
positionId:
|
|
2009
|
+
orderId: int64IdField("Pending order id (from get_orders) to amend").optional(),
|
|
2010
|
+
positionId: int64IdField("Open position id (from get_positions) to set SL/TP on").optional(),
|
|
1782
2011
|
limitPrice: z.coerce.number().positive().optional().describe("New limit price (pending order only)"),
|
|
1783
2012
|
stopPrice: z.coerce.number().positive().optional().describe("New stop price (pending order only)"),
|
|
1784
2013
|
stopLoss: z.coerce.number().positive().optional().describe("New absolute stop-loss price"),
|
|
@@ -1787,21 +2016,23 @@ function registerTools(server, pool) {
|
|
|
1787
2016
|
}, async (args) => {
|
|
1788
2017
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1789
2018
|
if (isToolError(resolved)) return resolved;
|
|
1790
|
-
const accountId =
|
|
2019
|
+
const accountId = resolved.accountId;
|
|
1791
2020
|
try {
|
|
1792
2021
|
if (args.orderId == null === (args.positionId == null)) return fail(new CTraderError("INVALID_TARGET", "Provide exactly one of orderId or positionId"));
|
|
2022
|
+
if (args.limitPrice == null && args.stopPrice == null && args.stopLoss == null && args.takeProfit == null) return fail(new CTraderError("NO_CHANGES", "Provide at least one price or protection change"));
|
|
1793
2023
|
if (args.positionId != null) {
|
|
2024
|
+
if (args.limitPrice != null || args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "Position protection changes accept only stopLoss/takeProfit"));
|
|
1794
2025
|
const payload = {
|
|
1795
2026
|
ctidTraderAccountId: resolved.accountId,
|
|
1796
2027
|
positionId: args.positionId
|
|
1797
2028
|
};
|
|
1798
2029
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1799
2030
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1800
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload));
|
|
2031
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload), AMEND_SUCCESS_TYPES);
|
|
1801
2032
|
return ok({
|
|
1802
2033
|
accountId,
|
|
1803
2034
|
executionType,
|
|
1804
|
-
positionId:
|
|
2035
|
+
positionId: args.positionId,
|
|
1805
2036
|
execution: summary
|
|
1806
2037
|
});
|
|
1807
2038
|
}
|
|
@@ -1813,11 +2044,11 @@ function registerTools(server, pool) {
|
|
|
1813
2044
|
if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
|
|
1814
2045
|
if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
|
|
1815
2046
|
if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
|
|
1816
|
-
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload));
|
|
2047
|
+
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload), AMEND_SUCCESS_TYPES);
|
|
1817
2048
|
return ok({
|
|
1818
2049
|
accountId,
|
|
1819
2050
|
executionType,
|
|
1820
|
-
orderId:
|
|
2051
|
+
orderId: args.orderId,
|
|
1821
2052
|
execution: summary
|
|
1822
2053
|
});
|
|
1823
2054
|
} catch (err) {
|
|
@@ -1828,36 +2059,41 @@ function registerTools(server, pool) {
|
|
|
1828
2059
|
description: "Close an open position (fully or partially) on a connected cTrader account. Pass the positionId from get_positions. volumeLots defaults to the full position size when omitted. Pass `accountId` when several accounts are connected.",
|
|
1829
2060
|
inputSchema: {
|
|
1830
2061
|
accountId: accountIdField,
|
|
1831
|
-
positionId:
|
|
2062
|
+
positionId: int64IdField("Position id from get_positions"),
|
|
1832
2063
|
volumeLots: z.coerce.number().positive().optional().describe("Lots to close; omit to close the whole position")
|
|
1833
2064
|
}
|
|
1834
2065
|
}, async (args) => {
|
|
1835
2066
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1836
2067
|
if (isToolError(resolved)) return resolved;
|
|
1837
|
-
const accountId =
|
|
2068
|
+
const accountId = resolved.accountId;
|
|
1838
2069
|
try {
|
|
1839
2070
|
const pos = ((await pool.request(accountId, PayloadType.OA_RECONCILE_REQ, {
|
|
1840
2071
|
ctidTraderAccountId: resolved.accountId,
|
|
1841
2072
|
returnProtectionOrders: true
|
|
1842
|
-
})).message.position ?? []).find((p) => str(p.positionId) ===
|
|
1843
|
-
if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${
|
|
2073
|
+
})).message.position ?? []).find((p) => str(p.positionId) === args.positionId);
|
|
2074
|
+
if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${args.positionId}`));
|
|
1844
2075
|
const td = pos.tradeData ?? {};
|
|
1845
2076
|
const fullVolume = num(td.volume);
|
|
2077
|
+
if (!Number.isSafeInteger(fullVolume) || fullVolume <= 0) return fail(new CTraderError("INVALID_POSITION_VOLUME", `Position ${args.positionId} has invalid broker volume ${String(fullVolume)}`));
|
|
1846
2078
|
let volume = fullVolume;
|
|
1847
2079
|
if (args.volumeLots != null) {
|
|
1848
|
-
const
|
|
2080
|
+
const positionSymbolId = str(td.symbolId);
|
|
2081
|
+
if (!isCanonicalInt64(positionSymbolId)) return fail(new CTraderError("INVALID_POSITION_SYMBOL", `Position ${args.positionId} has an invalid symbol id`));
|
|
2082
|
+
const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, positionSymbolId);
|
|
1849
2083
|
volume = lotsToVolume(args.volumeLots, num(symbol.lotSize));
|
|
2084
|
+
const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
|
|
2085
|
+
if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
|
|
1850
2086
|
if (volume > fullVolume) return fail(new CTraderError("VOLUME_EXCEEDS_POSITION", `Requested ${String(volume)} exceeds position volume ${String(fullVolume)}`));
|
|
1851
2087
|
}
|
|
1852
2088
|
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CLOSE_POSITION_REQ, {
|
|
1853
2089
|
ctidTraderAccountId: resolved.accountId,
|
|
1854
2090
|
positionId: args.positionId,
|
|
1855
2091
|
volume
|
|
1856
|
-
}));
|
|
2092
|
+
}), CLOSE_SUCCESS_TYPES);
|
|
1857
2093
|
return ok({
|
|
1858
2094
|
accountId,
|
|
1859
2095
|
executionType,
|
|
1860
|
-
positionId:
|
|
2096
|
+
positionId: args.positionId,
|
|
1861
2097
|
volume,
|
|
1862
2098
|
execution: summary
|
|
1863
2099
|
});
|
|
@@ -1869,21 +2105,21 @@ function registerTools(server, pool) {
|
|
|
1869
2105
|
description: "Cancel a pending (not-yet-filled) order on a connected cTrader account. Pass the orderId from get_orders. Pass `accountId` when several accounts are connected.",
|
|
1870
2106
|
inputSchema: {
|
|
1871
2107
|
accountId: accountIdField,
|
|
1872
|
-
orderId:
|
|
2108
|
+
orderId: int64IdField("Pending order id from get_orders")
|
|
1873
2109
|
}
|
|
1874
2110
|
}, async (args) => {
|
|
1875
2111
|
const resolved = resolveAccount(pool, args.accountId);
|
|
1876
2112
|
if (isToolError(resolved)) return resolved;
|
|
1877
|
-
const accountId =
|
|
2113
|
+
const accountId = resolved.accountId;
|
|
1878
2114
|
try {
|
|
1879
2115
|
const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CANCEL_ORDER_REQ, {
|
|
1880
2116
|
ctidTraderAccountId: resolved.accountId,
|
|
1881
2117
|
orderId: args.orderId
|
|
1882
|
-
}),
|
|
2118
|
+
}), CANCEL_SUCCESS_TYPES);
|
|
1883
2119
|
return ok({
|
|
1884
2120
|
accountId,
|
|
1885
2121
|
executionType,
|
|
1886
|
-
orderId:
|
|
2122
|
+
orderId: args.orderId,
|
|
1887
2123
|
execution: summary
|
|
1888
2124
|
});
|
|
1889
2125
|
} catch (err) {
|
|
@@ -1907,17 +2143,16 @@ function summariseExecution(message) {
|
|
|
1907
2143
|
/**
|
|
1908
2144
|
* Turn a write-tool reply into a definite outcome. A cTrader order op is
|
|
1909
2145
|
* expected to come back as a ProtoOAExecutionEvent (2126); anything else means
|
|
1910
|
-
* we can't confirm the write landed
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
* path and must fail closed.
|
|
2146
|
+
* we can't confirm the write landed. Each operation passes an explicit
|
|
2147
|
+
* allowlist of execution types that prove its intended outcome; missing,
|
|
2148
|
+
* unknown, or merely unrelated execution events fail closed.
|
|
1914
2149
|
*/
|
|
1915
|
-
function assertExecution(res,
|
|
2150
|
+
function assertExecution(res, allowedTypes) {
|
|
1916
2151
|
if (res.payloadType !== PayloadType.OA_EXECUTION_EVENT) throw new CTraderError("UNEXPECTED_EXECUTION_REPLY", `Expected an execution event (${String(PayloadType.OA_EXECUTION_EVENT)}) but got payloadType ${String(res.payloadType)}; cannot confirm the order`);
|
|
1917
2152
|
const summary = summariseExecution(res.message);
|
|
1918
2153
|
const code = res.message.executionType != null ? num(res.message.executionType) : null;
|
|
1919
2154
|
const executionType = typeof summary.executionType === "string" ? summary.executionType : "UNKNOWN";
|
|
1920
|
-
if (code
|
|
2155
|
+
if (code == null || !allowedTypes.has(code)) throw new CTraderError(code == null ? "MISSING_EXECUTION_TYPE" : executionType, `cTrader returned an execution that does not confirm this operation (${executionType})`, {
|
|
1921
2156
|
orderId: str(summary.orderId) || void 0,
|
|
1922
2157
|
positionId: str(summary.positionId) || void 0
|
|
1923
2158
|
});
|
|
@@ -1955,6 +2190,7 @@ function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
|
|
|
1955
2190
|
* account it names (see client.ts). No account is ever chosen implicitly when
|
|
1956
2191
|
* several are connected — the tools require an explicit `accountId` in that case.
|
|
1957
2192
|
*/
|
|
2193
|
+
const packageVersion = createRequire(import.meta.url)("../package.json").version;
|
|
1958
2194
|
function log(msg) {
|
|
1959
2195
|
process.stderr.write(`[ctrader-mcp] ${msg}\n`);
|
|
1960
2196
|
}
|
|
@@ -1978,7 +2214,7 @@ async function main() {
|
|
|
1978
2214
|
const pool = new CTraderPool(registry);
|
|
1979
2215
|
const server = new McpServer({
|
|
1980
2216
|
name: "ctrader-mcp-server",
|
|
1981
|
-
version:
|
|
2217
|
+
version: packageVersion
|
|
1982
2218
|
});
|
|
1983
2219
|
registerTools(server, pool);
|
|
1984
2220
|
const shutdown = () => {
|
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.6",
|
|
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,8 +21,8 @@
|
|
|
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.
|
|
25
|
-
"@alfe.ai/config": "0.4.
|
|
24
|
+
"@alfe.ai/agent-api-client": "0.15.0",
|
|
25
|
+
"@alfe.ai/config": "0.4.1"
|
|
26
26
|
},
|
|
27
27
|
"license": "UNLICENSED",
|
|
28
28
|
"homepage": "https://alfe.ai",
|
package/proto/ctrader.proto
CHANGED
|
@@ -29,7 +29,7 @@ message ProtoErrorRes {
|
|
|
29
29
|
optional uint32 payloadType = 1;
|
|
30
30
|
required string errorCode = 2;
|
|
31
31
|
optional string description = 3;
|
|
32
|
-
optional
|
|
32
|
+
optional uint64 maintenanceEndTimestamp = 4;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Heartbeat (payloadType 51). Keep-alive; no fields beyond payloadType.
|
|
@@ -53,6 +53,13 @@ enum ProtoOATradeSide {
|
|
|
53
53
|
SELL = 2;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
enum ProtoOAOrderTriggerMethod {
|
|
57
|
+
TRADE = 1;
|
|
58
|
+
OPPOSITE = 2;
|
|
59
|
+
DOUBLE_TRADE = 3;
|
|
60
|
+
DOUBLE_OPPOSITE = 4;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
enum ProtoOAQuoteType {
|
|
57
64
|
BID = 1;
|
|
58
65
|
ASK = 2;
|
|
@@ -118,7 +125,7 @@ message ProtoOAGetAccountListByAccessTokenReq {
|
|
|
118
125
|
message ProtoOACtidTraderAccount {
|
|
119
126
|
required uint64 ctidTraderAccountId = 1;
|
|
120
127
|
optional bool isLive = 2;
|
|
121
|
-
optional
|
|
128
|
+
optional int64 traderLogin = 3;
|
|
122
129
|
optional int64 lastClosingDealTimestamp = 4;
|
|
123
130
|
optional int64 lastBalanceUpdateTimestamp = 5;
|
|
124
131
|
optional string brokerTitleShort = 6;
|
|
@@ -164,12 +171,13 @@ message ProtoOATrader {
|
|
|
164
171
|
optional bool isLimitedRisk = 18;
|
|
165
172
|
optional uint32 limitedRiskMarginCalculationStrategy = 19;
|
|
166
173
|
optional uint32 moneyDigits = 20;
|
|
167
|
-
optional
|
|
174
|
+
optional bool fairStopOut = 21;
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
message ProtoOATraderRes {
|
|
171
|
-
optional uint32
|
|
172
|
-
required
|
|
178
|
+
optional uint32 payloadType = 1;
|
|
179
|
+
required int64 ctidTraderAccountId = 2;
|
|
180
|
+
required ProtoOATrader trader = 3;
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
// ── Reconcile (open positions + pending orders) ─────────────────────────
|
|
@@ -182,7 +190,8 @@ message ProtoOATradeData {
|
|
|
182
190
|
optional string label = 5;
|
|
183
191
|
optional bool guaranteedStopLoss = 6;
|
|
184
192
|
optional string comment = 7;
|
|
185
|
-
optional
|
|
193
|
+
optional string measurementUnits = 8;
|
|
194
|
+
optional uint64 closeTimestamp = 9;
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
message ProtoOAPosition {
|
|
@@ -198,8 +207,8 @@ message ProtoOAPosition {
|
|
|
198
207
|
optional double marginRate = 10;
|
|
199
208
|
optional int64 mirroringCommission = 11;
|
|
200
209
|
optional bool guaranteedStopLoss = 12;
|
|
201
|
-
optional
|
|
202
|
-
optional
|
|
210
|
+
optional uint64 usedMargin = 13;
|
|
211
|
+
optional ProtoOAOrderTriggerMethod stopLossTriggerMethod = 14;
|
|
203
212
|
optional uint32 moneyDigits = 15;
|
|
204
213
|
optional bool trailingStopLoss = 16;
|
|
205
214
|
}
|
|
@@ -230,7 +239,7 @@ message ProtoOAOrder {
|
|
|
230
239
|
optional int64 relativeTakeProfit = 21;
|
|
231
240
|
optional bool isStopOut = 22;
|
|
232
241
|
optional bool trailingStopLoss = 23;
|
|
233
|
-
optional
|
|
242
|
+
optional ProtoOAOrderTriggerMethod stopTriggerMethod = 24;
|
|
234
243
|
}
|
|
235
244
|
|
|
236
245
|
message ProtoOAReconcileReq {
|
|
@@ -267,7 +276,7 @@ message ProtoOASymbolsListReq {
|
|
|
267
276
|
message ProtoOAArchivedSymbol {
|
|
268
277
|
required int64 symbolId = 1;
|
|
269
278
|
required string name = 2;
|
|
270
|
-
|
|
279
|
+
required int64 utcLastUpdateTimestamp = 3;
|
|
271
280
|
optional string description = 4;
|
|
272
281
|
}
|
|
273
282
|
|
|
@@ -280,8 +289,8 @@ message ProtoOASymbolsListRes {
|
|
|
280
289
|
|
|
281
290
|
message ProtoOASymbol {
|
|
282
291
|
required int64 symbolId = 1;
|
|
283
|
-
|
|
284
|
-
|
|
292
|
+
required int32 digits = 2;
|
|
293
|
+
required int32 pipPosition = 3;
|
|
285
294
|
optional bool enableShortSelling = 4;
|
|
286
295
|
optional bool guaranteedStopLoss = 5;
|
|
287
296
|
optional uint32 swapRollover3Days = 6;
|
|
@@ -293,7 +302,7 @@ message ProtoOASymbol {
|
|
|
293
302
|
optional int64 maxVolume = 9;
|
|
294
303
|
optional int64 minVolume = 10;
|
|
295
304
|
optional int64 stepVolume = 11;
|
|
296
|
-
optional
|
|
305
|
+
optional uint64 maxExposure = 12;
|
|
297
306
|
// lotSize is canonical field 30 (NOT 21 — field 21 is the deprecated
|
|
298
307
|
// minCommission). Reading it at 21 yields 0 for XAUUSD → SYMBOL_NO_LOTSIZE
|
|
299
308
|
// and mis-sizes volume for any symbol where field 21 is non-zero.
|
|
@@ -316,7 +325,7 @@ message ProtoOASymbolByIdRes {
|
|
|
316
325
|
// ── Trendbars (market data / candles) ───────────────────────────────────
|
|
317
326
|
|
|
318
327
|
message ProtoOATrendbar {
|
|
319
|
-
|
|
328
|
+
required int64 volume = 3;
|
|
320
329
|
optional ProtoOATrendbarPeriod period = 4;
|
|
321
330
|
optional int64 low = 5;
|
|
322
331
|
optional uint64 deltaOpen = 6;
|
|
@@ -601,7 +610,7 @@ message ProtoOANewOrderReq {
|
|
|
601
610
|
optional int64 relativeTakeProfit = 20;
|
|
602
611
|
optional bool guaranteedStopLoss = 21;
|
|
603
612
|
optional bool trailingStopLoss = 22;
|
|
604
|
-
optional
|
|
613
|
+
optional ProtoOAOrderTriggerMethod stopTriggerMethod = 23;
|
|
605
614
|
}
|
|
606
615
|
|
|
607
616
|
message ProtoOAAmendOrderReq {
|
|
@@ -614,11 +623,12 @@ message ProtoOAAmendOrderReq {
|
|
|
614
623
|
optional int64 expirationTimestamp = 7;
|
|
615
624
|
optional double stopLoss = 8;
|
|
616
625
|
optional double takeProfit = 9;
|
|
617
|
-
optional
|
|
618
|
-
optional int64
|
|
619
|
-
optional
|
|
620
|
-
optional bool
|
|
621
|
-
optional
|
|
626
|
+
optional int32 slippageInPoints = 10;
|
|
627
|
+
optional int64 relativeStopLoss = 11;
|
|
628
|
+
optional int64 relativeTakeProfit = 12;
|
|
629
|
+
optional bool guaranteedStopLoss = 13;
|
|
630
|
+
optional bool trailingStopLoss = 14;
|
|
631
|
+
optional ProtoOAOrderTriggerMethod stopTriggerMethod = 15;
|
|
622
632
|
}
|
|
623
633
|
|
|
624
634
|
message ProtoOAAmendPositionSLTPReq {
|
|
@@ -627,11 +637,9 @@ message ProtoOAAmendPositionSLTPReq {
|
|
|
627
637
|
required int64 positionId = 3;
|
|
628
638
|
optional double stopLoss = 4;
|
|
629
639
|
optional double takeProfit = 5;
|
|
630
|
-
optional bool guaranteedStopLoss =
|
|
631
|
-
optional double stopLossTriggerMethod = 7;
|
|
640
|
+
optional bool guaranteedStopLoss = 7;
|
|
632
641
|
optional bool trailingStopLoss = 8;
|
|
633
|
-
optional
|
|
634
|
-
optional double relativeTakeProfit = 10;
|
|
642
|
+
optional ProtoOAOrderTriggerMethod stopLossTriggerMethod = 9;
|
|
635
643
|
}
|
|
636
644
|
|
|
637
645
|
message ProtoOAClosePositionReq {
|