@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 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 = Number(idRaw);
88
- if (!idRaw || !Number.isInteger(accountId) || accountId <= 0) continue;
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
- registry.set(idRaw, {
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: account.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
- const innerErr = InnerType.verify(payload);
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 innerBytes = InnerType.encode(InnerType.create(payload)).finish();
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 (maxVolume > 0 && volume > maxVolume) return `volume ${String(volume)} exceeds the symbol maximum ${String(maxVolume)}`;
379
- if (stepVolume > 0 && (volume - minVolume) % stepVolume !== 0) return `volume ${String(volume)} does not align to the symbol step ${String(stepVolume)} (offset from min ${String(minVolume)})`;
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.connectPromise ??= this.doStart();
567
- return this.connectPromise;
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 doStart() {
570
- this.conn = await this.connectFn(this.host, CTRADER_PORT);
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(this.conn);
573
- await this.appAuth();
574
- this.startHeartbeat();
575
- this.startWatchdog();
576
- log$1(`Connected + app-authenticated to ${this.host}`);
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
- for (const frame of this.parser.push(chunk)) this.dispatch(frame);
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
- this.conn = null;
827
+ if (!dead) return;
828
+ this.handleDrop(dead);
684
829
  try {
685
- dead?.destroy();
830
+ dead.destroy();
686
831
  } catch {}
687
- this.handleDrop();
688
832
  }
689
- handleDrop() {
690
- if (!this.conn && this.heartbeatTimer === null && this.reconnectScheduled) return;
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.reconnect();
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
- if (this.closing) return;
707
- const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
708
- this.reconnectAttempts += 1;
709
- await new Promise((r) => setTimeout(r, delay));
710
- if (this.closing) return;
711
- try {
712
- this.conn = await this.connectFn(this.host, CTRADER_PORT);
713
- this.lastInboundAt = Date.now();
714
- this.wireConnection(this.conn);
715
- await this.appAuth();
716
- this.startHeartbeat();
717
- this.startWatchdog();
718
- this.reconnectAttempts = 0;
719
- this.reconnectScheduled = false;
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?.write(frame);
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 account's
868
- * `ctidTraderAccountId` is NOT auto-injected into the payload — callers pass
869
- * the full payload but the request is guaranteed to run on the socket that
870
- * serves this account's host.
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, payload);
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
- /** Execution types that mean the write did NOT succeed as intended. */
992
- const FAILED_EXECUTION_TYPES = new Set([
993
- 5,
994
- 6,
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: String(config.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, numericAccountId, symbolId) {
1294
+ async function getSymbolDetail(pool, accountId, ctidTraderAccountId, symbolId) {
1098
1295
  const symbols = (await pool.request(accountId, PayloadType.OA_SYMBOL_BY_ID_REQ, {
1099
- ctidTraderAccountId: numericAccountId,
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 ${String(symbolId)} on this account`);
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, numericAccountId, symbolId, kind, handler, collect) {
1156
- const accountId = String(numericAccountId);
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}:${String(symbolId)}:${kind}`, async () => {
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) !== String(symbolId)) return;
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: numericAccountId,
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: numericAccountId,
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 ${String(symbolId)}: ${err instanceof Error ? err.message : String(err)}\n`);
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 = z.coerce.string().optional().describe("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.");
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 register = server.registerTool.bind(server);
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(String(resolved.accountId), PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
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) || String(resolved.accountId),
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(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
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: String(resolved.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(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
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: String(resolved.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(String(resolved.accountId), PayloadType.OA_SYMBOLS_LIST_REQ, {
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: String(resolved.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: z.coerce.number().int().positive().describe("The symbol id from get_symbols"),
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(String(resolved.accountId), PayloadType.OA_GET_TRENDBARS_REQ, {
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: String(resolved.accountId),
1343
- symbolId: String(args.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols")
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 = String(resolved.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) || String(args.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
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 = String(resolved.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 ${String(args.symbolId)} within ${String(args.waitMs)}ms — the market may be closed or the symbol not quoted on this account`));
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: String(args.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
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 = String(resolved.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 ${String(args.symbolId)} within ${String(args.collectMs)}ms — this broker/symbol may not publish Level 2 via the Open API, or the market is closed`));
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: String(args.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
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 = String(resolved.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: String(args.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 = String(resolved.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 = String(resolved.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
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 = String(resolved.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 ${String(args.symbolId)} has no lotSize; cannot size the margin quote`));
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: String(args.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 = String(resolved.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: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
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 = String(resolved.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 ${String(args.symbolId)} has no lotSize; cannot size the order`));
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 (args.stopLossDistance != null) payload.relativeStopLoss = Math.round(args.stopLossDistance * PRICE_SCALE);
1759
- if (args.takeProfitDistance != null) payload.relativeTakeProfit = Math.round(args.takeProfitDistance * PRICE_SCALE);
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 (args.expiresAt != null) payload.expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
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: z.coerce.number().int().positive().optional().describe("Pending order id (from get_orders) to amend"),
1781
- positionId: z.coerce.number().int().positive().optional().describe("Open position id (from get_positions) to set SL/TP on"),
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 = String(resolved.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: String(args.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: String(args.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: z.coerce.number().int().positive().describe("Position id from get_positions"),
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 = String(resolved.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) === String(args.positionId));
1843
- if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${String(args.positionId)}`));
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 symbol = await getSymbolDetail(pool, accountId, resolved.accountId, num(td.symbolId));
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: String(args.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: z.coerce.number().int().positive().describe("Pending order id from get_orders")
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 = String(resolved.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
- }), new Set([5]));
2118
+ }), CANCEL_SUCCESS_TYPES);
1883
2119
  return ok({
1884
2120
  accountId,
1885
2121
  executionType,
1886
- orderId: String(args.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, and a rejection/cancel executionType means
1911
- * it explicitly did NOT. In both cases we throw a CTraderError so the tool
1912
- * result is an unambiguous error rather than a bare success — this is a money
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, extraOkTypes = /* @__PURE__ */ new Set()) {
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 != null && FAILED_EXECUTION_TYPES.has(code) && !extraOkTypes.has(code)) throw new CTraderError(executionType, `cTrader returned a non-fill execution (${executionType})`, {
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: "0.0.1"
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.5",
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.14.0",
25
- "@alfe.ai/config": "0.4.0"
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",
@@ -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 int64 maintenanceEndTimestamp = 4;
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 uint64 traderLogin = 3;
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 int64 fairStopOut = 21;
174
+ optional bool fairStopOut = 21;
168
175
  }
169
176
 
170
177
  message ProtoOATraderRes {
171
- optional uint32 payloadType = 1;
172
- required ProtoOATrader trader = 2;
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 double measurementUnits = 8;
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 int64 usedMargin = 13;
202
- optional uint32 stopLossTriggerMethod = 14;
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 uint32 stopTriggerMethod = 24;
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
- optional int64 utcLastUpdateTimestamp = 3;
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
- optional int64 digits = 2;
284
- optional int64 pipPosition = 3;
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 int64 maxExposure = 12;
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
- optional uint32 volume = 3;
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 uint32 stopTriggerMethod = 23;
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 int64 relativeStopLoss = 10;
618
- optional int64 relativeTakeProfit = 11;
619
- optional bool guaranteedStopLoss = 12;
620
- optional bool trailingStopLoss = 13;
621
- optional uint32 stopTriggerMethod = 14;
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 = 6;
631
- optional double stopLossTriggerMethod = 7;
640
+ optional bool guaranteedStopLoss = 7;
632
641
  optional bool trailingStopLoss = 8;
633
- optional double relativeStopLoss = 9;
634
- optional double relativeTakeProfit = 10;
642
+ optional ProtoOAOrderTriggerMethod stopLossTriggerMethod = 9;
635
643
  }
636
644
 
637
645
  message ProtoOAClosePositionReq {