@alfe.ai/ctrader-mcp 0.3.5 → 0.3.7

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. */
@@ -490,25 +591,48 @@ var CTraderError = class extends Error {
490
591
  * execution events for other sessions, …).
491
592
  */
492
593
  const LISTENABLE_EVENTS = new Set([PayloadType.OA_DEPTH_EVENT, PayloadType.OA_SPOT_EVENT]);
493
- const HEARTBEAT_INTERVAL_MS = 1e4;
594
+ /**
595
+ * Halved from 10s so the read-idle watchdog can detect a wedged socket before
596
+ * `REQUEST_TIMEOUT_MS` fires, while still tolerating two consecutive missed
597
+ * echoes (see `READ_IDLE_TIMEOUT_MS`). cTrader expects a heartbeat at least
598
+ * every 10s and drops an idle connection at ~30s, so 5s is well within
599
+ * protocol expectations. Heartbeats are protocol keepalives, not part of the
600
+ * historical-data payload class that gets rate limited.
601
+ */
602
+ const HEARTBEAT_INTERVAL_MS = 5e3;
494
603
  const REQUEST_TIMEOUT_MS = 2e4;
495
604
  const RECONNECT_BASE_MS = 1e3;
496
605
  const RECONNECT_MAX_MS = 3e4;
606
+ const MAX_PENDING_REQUESTS = 256;
497
607
  /**
498
- * Read-idle watchdog: cTrader echoes our 10s heartbeats and pushes its own
499
- * traffic, so a *healthy* socket is never silent for long. If NO inbound byte
500
- * arrives for this long we treat the socket as silently half-dead (a TCP
501
- * half-open with no FIN — the OS never fires `close`, so `handleDrop` never
502
- * runs and every request would otherwise time out at 20s indefinitely) and
503
- * force a reconnect. Set to 3× the heartbeat interval so a single dropped
504
- * heartbeat echo doesn't false-positive.
608
+ * Read-idle watchdog: cTrader echoes our heartbeats and pushes its own traffic,
609
+ * so a *healthy* socket is never silent for long. If NO inbound byte arrives
610
+ * for this long we treat the socket as silently half-dead (a TCP half-open with
611
+ * no FIN — the OS never fires `close`, so `handleDrop` never runs and every
612
+ * request would otherwise time out indefinitely) and force a reconnect. Set to
613
+ * 3× the heartbeat interval so two dropped heartbeat echoes don't false-positive.
505
614
  */
506
615
  const READ_IDLE_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS;
507
616
  /** How often the watchdog checks the read-idle clock. */
508
- const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS;
617
+ const WATCHDOG_INTERVAL_MS = HEARTBEAT_INTERVAL_MS / 2;
618
+ /**
619
+ * Detection must beat the request timeout, or the watchdog is useless to the
620
+ * caller that trips it: a wedged socket surfaces an ambiguous `REQUEST_TIMEOUT`
621
+ * (indistinguishable from a slow server) instead of the fast, honest,
622
+ * retryable `CONNECTION_DROPPED` the reconnect path exists to produce.
623
+ *
624
+ * That was the shipped state — 30s idle + 10s poll = up to 40s to detect, vs a
625
+ * 20s request timeout — so the 0.3.1 watchdog never spared the first caller.
626
+ * Worst-case detection is READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS.
627
+ */
628
+ if (READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS >= REQUEST_TIMEOUT_MS) throw new Error(`ctrader-mcp timing misconfigured: read-idle detection (${String(READ_IDLE_TIMEOUT_MS + WATCHDOG_INTERVAL_MS)}ms) must be faster than REQUEST_TIMEOUT_MS (${String(REQUEST_TIMEOUT_MS)}ms).`);
509
629
  function log$1(msg) {
510
630
  process.stderr.write(`[ctrader-mcp] ${msg}\n`);
511
631
  }
632
+ /** Convert a validated decimal account id to an exact protobuf int64 value. */
633
+ function accountIdLong(accountId) {
634
+ return protobuf.util.LongBits.from(accountId).toLong(false);
635
+ }
512
636
  /** Production TLS transport. */
513
637
  const tlsConnect = (host, port) => new Promise((resolve, reject) => {
514
638
  const socket = tls.connect({
@@ -548,6 +672,7 @@ var HostSocket = class {
548
672
  reconnectScheduled = false;
549
673
  closing = false;
550
674
  connectPromise = null;
675
+ reconnectPromise = null;
551
676
  /** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
552
677
  authedAccounts = /* @__PURE__ */ new Map();
553
678
  /** Serializes account-auth so concurrent tool calls don't double-auth. */
@@ -563,17 +688,44 @@ var HostSocket = class {
563
688
  }
564
689
  /** Connect the socket and run the app-auth handshake. Idempotent. */
565
690
  async start() {
566
- this.connectPromise ??= this.doStart();
567
- return this.connectPromise;
691
+ if (this.closing) throw new CTraderError("CLIENT_CLOSED", "Client is shutting down");
692
+ if (this.conn) return;
693
+ if (this.reconnectPromise) return this.reconnectPromise;
694
+ if (this.connectPromise) return this.connectPromise;
695
+ const attempt = this.establishConnection();
696
+ this.connectPromise = attempt;
697
+ try {
698
+ await attempt;
699
+ } finally {
700
+ if (this.connectPromise === attempt) this.connectPromise = null;
701
+ }
568
702
  }
569
- async doStart() {
570
- this.conn = await this.connectFn(this.host, CTRADER_PORT);
703
+ async establishConnection() {
704
+ const conn = await this.connectFn(this.host, CTRADER_PORT);
705
+ if (this.closing) {
706
+ conn.destroy();
707
+ throw new CTraderError("CLIENT_CLOSED", "Client closed while connecting");
708
+ }
709
+ this.parser = new FrameParser();
710
+ this.conn = conn;
571
711
  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}`);
712
+ this.wireConnection(conn);
713
+ try {
714
+ await this.appAuth();
715
+ if (this.conn !== conn) throw new CTraderError("CONNECTION_DROPPED", "Socket dropped during application authentication");
716
+ this.startHeartbeat();
717
+ this.startWatchdog();
718
+ log$1(`Connected + app-authenticated to ${this.host}`);
719
+ } catch (err) {
720
+ if (this.conn === conn) {
721
+ this.conn = null;
722
+ this.parser = new FrameParser();
723
+ try {
724
+ conn.destroy();
725
+ } catch {}
726
+ }
727
+ throw err;
728
+ }
577
729
  }
578
730
  /**
579
731
  * Ensure `accountId` is account-authed on this socket. Deduped: an account is
@@ -586,7 +738,7 @@ var HostSocket = class {
586
738
  let inFlight = this.accountAuthPromises.get(accountId);
587
739
  if (!inFlight) {
588
740
  inFlight = this.request(PayloadType.OA_ACCOUNT_AUTH_REQ, {
589
- ctidTraderAccountId: accountId,
741
+ ctidTraderAccountId: accountIdLong(accountId),
590
742
  accessToken
591
743
  }).then(() => {
592
744
  this.authedAccounts.set(accountId, accessToken);
@@ -612,14 +764,24 @@ var HostSocket = class {
612
764
  }
613
765
  wireConnection(conn) {
614
766
  conn.on("data", (chunk) => {
767
+ if (this.conn !== conn || this.closing) return;
615
768
  this.lastInboundAt = Date.now();
616
- for (const frame of this.parser.push(chunk)) this.dispatch(frame);
769
+ try {
770
+ for (const frame of this.parser.push(chunk)) this.dispatch(frame);
771
+ } catch (err) {
772
+ log$1(`Invalid frame stream (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
773
+ this.handleDrop(conn);
774
+ try {
775
+ conn.destroy();
776
+ } catch {}
777
+ }
617
778
  });
618
779
  conn.on("error", (err) => {
780
+ if (this.conn !== conn || this.closing) return;
619
781
  log$1(`Socket error (${this.host}): ${err.message}`);
620
782
  });
621
783
  conn.on("close", () => {
622
- if (!this.closing) this.handleDrop();
784
+ if (!this.closing) this.handleDrop(conn);
623
785
  });
624
786
  }
625
787
  async appAuth() {
@@ -680,18 +842,17 @@ var HostSocket = class {
680
842
  */
681
843
  forceReconnect() {
682
844
  const dead = this.conn;
683
- this.conn = null;
845
+ if (!dead) return;
846
+ this.handleDrop(dead);
684
847
  try {
685
- dead?.destroy();
848
+ dead.destroy();
686
849
  } catch {}
687
- this.handleDrop();
688
850
  }
689
- handleDrop() {
690
- if (!this.conn && this.heartbeatTimer === null && this.reconnectScheduled) return;
851
+ handleDrop(dropped) {
852
+ if (this.conn !== dropped) return;
691
853
  log$1(`Socket dropped (${this.host}) — attempting reconnect`);
692
854
  this.stopHeartbeat();
693
855
  this.stopWatchdog();
694
- this.reconnectScheduled = true;
695
856
  this.conn = null;
696
857
  this.parser = new FrameParser();
697
858
  this.authedAccounts.clear();
@@ -700,27 +861,32 @@ var HostSocket = class {
700
861
  req.reject(new CTraderError("CONNECTION_DROPPED", "Socket closed before a response arrived"));
701
862
  this.pending.delete(id);
702
863
  }
703
- this.reconnect();
864
+ if (!this.reconnectScheduled && !this.closing) {
865
+ this.reconnectScheduled = true;
866
+ const attempt = this.reconnect();
867
+ this.reconnectPromise = attempt;
868
+ attempt.then(() => {
869
+ if (this.reconnectPromise === attempt) this.reconnectPromise = null;
870
+ }, () => {
871
+ if (this.reconnectPromise === attempt) this.reconnectPromise = null;
872
+ });
873
+ }
704
874
  }
705
875
  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();
876
+ while (!this.closing) {
877
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
878
+ this.reconnectAttempts += 1;
879
+ await new Promise((r) => setTimeout(r, delay));
880
+ if (this.closing) return;
881
+ try {
882
+ await this.establishConnection();
883
+ this.reconnectAttempts = 0;
884
+ this.reconnectScheduled = false;
885
+ log$1(`Reconnected + re-app-authenticated (${this.host})`);
886
+ return;
887
+ } catch (err) {
888
+ log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
889
+ }
724
890
  }
725
891
  }
726
892
  dispatch(frame) {
@@ -754,6 +920,10 @@ var HostSocket = class {
754
920
  waiter.reject(err);
755
921
  return;
756
922
  }
923
+ if (decoded.payloadType !== waiter.expectedPayloadType) {
924
+ waiter.reject(new CTraderError("UNEXPECTED_RESPONSE", `Request payloadType ${String(waiter.requestPayloadType)} expected ${String(waiter.expectedPayloadType)} but received ${String(decoded.payloadType)}`));
925
+ return;
926
+ }
757
927
  waiter.resolve({
758
928
  payloadType: decoded.payloadType,
759
929
  message: decoded.message
@@ -783,6 +953,10 @@ var HostSocket = class {
783
953
  */
784
954
  async request(payloadType, payload) {
785
955
  if (!this.conn) throw new CTraderError("NOT_CONNECTED", `The cTrader socket to ${this.host} is not connected`);
956
+ 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`);
957
+ const expectedPayloadType = EXPECTED_RESPONSE[payloadType];
958
+ if (expectedPayloadType == null) throw new CTraderError("UNSUPPORTED_REQUEST", `No successful response contract is registered for payloadType ${String(payloadType)}`);
959
+ const conn = this.conn;
786
960
  const clientMsgId = randomUUID();
787
961
  const frame = encodeRequest(this.root, payloadType, payload, clientMsgId);
788
962
  return new Promise((resolve, reject) => {
@@ -792,11 +966,29 @@ var HostSocket = class {
792
966
  }, REQUEST_TIMEOUT_MS);
793
967
  timer.unref();
794
968
  this.pending.set(clientMsgId, {
969
+ requestPayloadType: payloadType,
970
+ expectedPayloadType,
795
971
  resolve,
796
972
  reject,
797
973
  timer
798
974
  });
799
- this.conn?.write(frame);
975
+ if (this.conn !== conn) {
976
+ clearTimeout(timer);
977
+ this.pending.delete(clientMsgId);
978
+ reject(new CTraderError("CONNECTION_DROPPED", "Socket changed before the request was written"));
979
+ return;
980
+ }
981
+ try {
982
+ conn.write(frame);
983
+ } catch (err) {
984
+ clearTimeout(timer);
985
+ this.pending.delete(clientMsgId);
986
+ reject(new CTraderError("REQUEST_WRITE_FAILED", err instanceof Error ? err.message : "The socket rejected the request write"));
987
+ this.handleDrop(conn);
988
+ try {
989
+ conn.destroy();
990
+ } catch {}
991
+ }
800
992
  });
801
993
  }
802
994
  /** Clean shutdown: stop heartbeat, fail waiters, destroy the socket. */
@@ -811,6 +1003,7 @@ var HostSocket = class {
811
1003
  }
812
1004
  this.conn?.destroy();
813
1005
  this.conn = null;
1006
+ this.connectPromise = null;
814
1007
  this.authedAccounts.clear();
815
1008
  this.eventListeners.clear();
816
1009
  }
@@ -864,10 +1057,10 @@ var CTraderPool = class {
864
1057
  * Route a request to a specific account. Resolves the account → its host
865
1058
  * socket, ensures the socket is connected + app-authed and the account is
866
1059
  * 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.
1060
+ * go through here so it lands on the RIGHT host socket. The routed registry
1061
+ * entry is authoritative: its `ctidTraderAccountId` is injected after the
1062
+ * caller payload, so host/auth selection and protobuf account identity can
1063
+ * never diverge.
871
1064
  *
872
1065
  * Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
873
1066
  * (fail closed — never fall back to another account).
@@ -877,7 +1070,10 @@ var CTraderPool = class {
877
1070
  if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
878
1071
  const socket = this.socketFor(config);
879
1072
  await socket.authenticateAccount(config.accountId, config.accessToken);
880
- return socket.request(payloadType, payload);
1073
+ return socket.request(payloadType, {
1074
+ ...payload,
1075
+ ctidTraderAccountId: accountIdLong(config.accountId)
1076
+ });
881
1077
  }
882
1078
  /**
883
1079
  * Register an unsolicited-event listener on the socket that serves
@@ -988,13 +1184,32 @@ const EXECUTION_TYPE_NAME = {
988
1184
  11: "ORDER_PARTIAL_FILL",
989
1185
  12: "BONUS_DEPOSIT_WITHDRAW"
990
1186
  };
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
1187
+ const PLACE_ORDER_SUCCESS_TYPES = new Set([
1188
+ 2,
1189
+ 3,
1190
+ 11
1191
+ ]);
1192
+ const AMEND_SUCCESS_TYPES = new Set([4]);
1193
+ const CLOSE_SUCCESS_TYPES = new Set([3, 11]);
1194
+ const CANCEL_SUCCESS_TYPES = new Set([5]);
1195
+ const WRITE_TOOL_NAMES = new Set([
1196
+ "place_order",
1197
+ "modify_order",
1198
+ "close_position",
1199
+ "cancel_order"
997
1200
  ]);
1201
+ const READ_ANNOTATIONS = {
1202
+ readOnlyHint: true,
1203
+ destructiveHint: false,
1204
+ idempotentHint: true,
1205
+ openWorldHint: true
1206
+ };
1207
+ const WRITE_ANNOTATIONS = {
1208
+ readOnlyHint: false,
1209
+ destructiveHint: true,
1210
+ idempotentHint: false,
1211
+ openWorldHint: true
1212
+ };
998
1213
  const TRENDBAR_PERIOD = {
999
1214
  M1: 1,
1000
1215
  M2: 2,
@@ -1037,7 +1252,7 @@ function num(v) {
1037
1252
  /** Serialize an account for a listing (both the registry and error payloads). */
1038
1253
  function describeAccount(config) {
1039
1254
  return {
1040
- accountId: String(config.accountId),
1255
+ accountId: config.accountId,
1041
1256
  isLive: config.isLive,
1042
1257
  host: config.host,
1043
1258
  broker: config.brokerName ?? null,
@@ -1094,12 +1309,12 @@ function isToolError(v) {
1094
1309
  return "content" in v;
1095
1310
  }
1096
1311
  /** Fetch a symbol's full detail (for lotSize / volume rules / digits). */
1097
- async function getSymbolDetail(pool, accountId, numericAccountId, symbolId) {
1312
+ async function getSymbolDetail(pool, accountId, ctidTraderAccountId, symbolId) {
1098
1313
  const symbols = (await pool.request(accountId, PayloadType.OA_SYMBOL_BY_ID_REQ, {
1099
- ctidTraderAccountId: numericAccountId,
1314
+ ctidTraderAccountId,
1100
1315
  symbolId: [symbolId]
1101
1316
  })).message.symbol ?? [];
1102
- if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${String(symbolId)} on this account`);
1317
+ if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${symbolId} on this account`);
1103
1318
  return symbols[0];
1104
1319
  }
1105
1320
  /** Unref'd sleep so a pending collection window never keeps the process alive. */
@@ -1146,25 +1361,42 @@ const LIVE_SUBSCRIPTION = {
1146
1361
  event: PayloadType.OA_SPOT_EVENT
1147
1362
  }
1148
1363
  };
1364
+ const MAX_SIGNED_INT64 = 9223372036854775807n;
1365
+ function isCanonicalInt64(value) {
1366
+ if (!/^[1-9][0-9]*$/.test(value)) return false;
1367
+ try {
1368
+ return BigInt(value) <= MAX_SIGNED_INT64;
1369
+ } catch {
1370
+ return false;
1371
+ }
1372
+ }
1373
+ /**
1374
+ * cTrader identifiers are int64 values and are returned to tools as decimal
1375
+ * strings. Accept safe JSON numbers for compatibility, but never round an
1376
+ * unsafe number or exponent/decimal string through JavaScript Number.
1377
+ */
1378
+ function int64IdField(description) {
1379
+ 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);
1380
+ }
1149
1381
  /**
1150
1382
  * Subscribe → run `collect` while `handler` receives this symbol's events →
1151
1383
  * ALWAYS unsubscribe + unregister (finally). The unsubscribe is best-effort:
1152
1384
  * its failure is logged, never masks the result, and a truly leaked
1153
1385
  * subscription dies with the socket.
1154
1386
  */
1155
- async function withLiveSubscription(pool, numericAccountId, symbolId, kind, handler, collect) {
1156
- const accountId = String(numericAccountId);
1387
+ async function withLiveSubscription(pool, ctidTraderAccountId, symbolId, kind, handler, collect) {
1388
+ const accountId = ctidTraderAccountId;
1157
1389
  const sub = LIVE_SUBSCRIPTION[kind];
1158
- return withLiveDataLock(`${accountId}:${String(symbolId)}:${kind}`, async () => {
1390
+ return withLiveDataLock(`${accountId}:${symbolId}:${kind}`, async () => {
1159
1391
  const unregister = pool.onAccountEvent(accountId, (event) => {
1160
1392
  if (event.payloadType !== sub.event) return;
1161
1393
  if (str(event.message.ctidTraderAccountId) !== accountId) return;
1162
- if (str(event.message.symbolId) !== String(symbolId)) return;
1394
+ if (str(event.message.symbolId) !== symbolId) return;
1163
1395
  handler(event.message);
1164
1396
  });
1165
1397
  try {
1166
1398
  await pool.request(accountId, sub.subscribe, {
1167
- ctidTraderAccountId: numericAccountId,
1399
+ ctidTraderAccountId,
1168
1400
  symbolId: [symbolId]
1169
1401
  });
1170
1402
  return await collect();
@@ -1172,11 +1404,11 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
1172
1404
  unregister();
1173
1405
  try {
1174
1406
  await pool.request(accountId, sub.unsubscribe, {
1175
- ctidTraderAccountId: numericAccountId,
1407
+ ctidTraderAccountId,
1176
1408
  symbolId: [symbolId]
1177
1409
  });
1178
1410
  } 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`);
1411
+ process.stderr.write(`[ctrader-mcp] Best-effort ${kind} unsubscribe failed for symbol ${symbolId}: ${err instanceof Error ? err.message : String(err)}\n`);
1180
1412
  }
1181
1413
  }
1182
1414
  });
@@ -1187,9 +1419,13 @@ async function withLiveSubscription(pool, numericAccountId, symbolId, kind, hand
1187
1419
  * required-when-multiple rule at runtime so the model gets a helpful listing
1188
1420
  * instead of a bare validation error.
1189
1421
  */
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.");
1422
+ 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
1423
  function registerTools(server, pool) {
1192
- const register = server.registerTool.bind(server);
1424
+ const rawRegister = server.registerTool.bind(server);
1425
+ const register = (name, definition, handler) => rawRegister(name, {
1426
+ ...definition,
1427
+ annotations: WRITE_TOOL_NAMES.has(name) ? WRITE_ANNOTATIONS : READ_ANNOTATIONS
1428
+ }, handler);
1193
1429
  register("get_accounts", {
1194
1430
  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
1431
  inputSchema: {}
@@ -1207,10 +1443,10 @@ function registerTools(server, pool) {
1207
1443
  const resolved = resolveAccount(pool, args.accountId);
1208
1444
  if (isToolError(resolved)) return resolved;
1209
1445
  try {
1210
- const trader = (await pool.request(String(resolved.accountId), PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
1446
+ const trader = (await pool.request(resolved.accountId, PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
1211
1447
  const moneyDigits = trader.moneyDigits != null ? num(trader.moneyDigits) : 2;
1212
1448
  return ok({
1213
- ctidTraderAccountId: str(trader.ctidTraderAccountId) || String(resolved.accountId),
1449
+ ctidTraderAccountId: str(trader.ctidTraderAccountId) || resolved.accountId,
1214
1450
  balance: moneyToDecimal(num(trader.balance), moneyDigits),
1215
1451
  balanceRaw: str(trader.balance) || "0",
1216
1452
  moneyDigits,
@@ -1230,12 +1466,12 @@ function registerTools(server, pool) {
1230
1466
  const resolved = resolveAccount(pool, args.accountId);
1231
1467
  if (isToolError(resolved)) return resolved;
1232
1468
  try {
1233
- const positions = (await pool.request(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
1469
+ const positions = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
1234
1470
  ctidTraderAccountId: resolved.accountId,
1235
1471
  returnProtectionOrders: true
1236
1472
  })).message.position ?? [];
1237
1473
  return ok({
1238
- accountId: String(resolved.accountId),
1474
+ accountId: resolved.accountId,
1239
1475
  positions: positions.map((p) => {
1240
1476
  const td = p.tradeData ?? {};
1241
1477
  return {
@@ -1261,13 +1497,13 @@ function registerTools(server, pool) {
1261
1497
  const resolved = resolveAccount(pool, args.accountId);
1262
1498
  if (isToolError(resolved)) return resolved;
1263
1499
  try {
1264
- const orders = (await pool.request(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
1500
+ const orders = (await pool.request(resolved.accountId, PayloadType.OA_RECONCILE_REQ, {
1265
1501
  ctidTraderAccountId: resolved.accountId,
1266
1502
  returnProtectionOrders: true
1267
1503
  })).message.order ?? [];
1268
1504
  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
1505
  return ok({
1270
- accountId: String(resolved.accountId),
1506
+ accountId: resolved.accountId,
1271
1507
  orders: orders.map((o) => {
1272
1508
  const td = o.tradeData ?? {};
1273
1509
  return {
@@ -1291,13 +1527,13 @@ function registerTools(server, pool) {
1291
1527
  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
1528
  inputSchema: {
1293
1529
  accountId: accountIdField,
1294
- nameFilter: z.string().optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
1530
+ nameFilter: z.string().max(100).optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
1295
1531
  }
1296
1532
  }, async (args) => {
1297
1533
  const resolved = resolveAccount(pool, args.accountId);
1298
1534
  if (isToolError(resolved)) return resolved;
1299
1535
  try {
1300
- let symbols = (await pool.request(String(resolved.accountId), PayloadType.OA_SYMBOLS_LIST_REQ, {
1536
+ let symbols = (await pool.request(resolved.accountId, PayloadType.OA_SYMBOLS_LIST_REQ, {
1301
1537
  ctidTraderAccountId: resolved.accountId,
1302
1538
  includeArchivedSymbols: false
1303
1539
  })).message.symbol ?? [];
@@ -1306,7 +1542,7 @@ function registerTools(server, pool) {
1306
1542
  symbols = symbols.filter((s) => str(s.symbolName).toLowerCase().includes(needle));
1307
1543
  }
1308
1544
  return ok({
1309
- accountId: String(resolved.accountId),
1545
+ accountId: resolved.accountId,
1310
1546
  count: symbols.length,
1311
1547
  symbols: symbols.map((s) => ({
1312
1548
  symbolId: str(s.symbolId),
@@ -1323,7 +1559,7 @@ function registerTools(server, pool) {
1323
1559
  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
1560
  inputSchema: {
1325
1561
  accountId: accountIdField,
1326
- symbolId: z.coerce.number().int().positive().describe("The symbol id from get_symbols"),
1562
+ symbolId: int64IdField("The symbol id from get_symbols"),
1327
1563
  period: z.enum(Object.keys(TRENDBAR_PERIOD)).default("H1").describe("Candle period: M1, M5, M15, M30, H1, H4, D1, W1, MN1, etc."),
1328
1564
  count: z.coerce.number().int().min(1).max(1e3).default(50).describe("Number of most-recent bars to return (1-1000)")
1329
1565
  }
@@ -1332,15 +1568,15 @@ function registerTools(server, pool) {
1332
1568
  if (isToolError(resolved)) return resolved;
1333
1569
  try {
1334
1570
  const period = TRENDBAR_PERIOD[args.period];
1335
- const bars = (await pool.request(String(resolved.accountId), PayloadType.OA_GET_TRENDBARS_REQ, {
1571
+ const bars = (await pool.request(resolved.accountId, PayloadType.OA_GET_TRENDBARS_REQ, {
1336
1572
  ctidTraderAccountId: resolved.accountId,
1337
1573
  symbolId: args.symbolId,
1338
1574
  period,
1339
1575
  count: args.count
1340
1576
  })).message.trendbar ?? [];
1341
1577
  return ok({
1342
- accountId: String(resolved.accountId),
1343
- symbolId: String(args.symbolId),
1578
+ accountId: resolved.accountId,
1579
+ symbolId: args.symbolId,
1344
1580
  period: args.period,
1345
1581
  bars: bars.map((b) => decodeTrendbar(b))
1346
1582
  });
@@ -1352,19 +1588,19 @@ function registerTools(server, pool) {
1352
1588
  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
1589
  inputSchema: {
1354
1590
  accountId: accountIdField,
1355
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols")
1591
+ symbolId: int64IdField("Symbol id from get_symbols")
1356
1592
  }
1357
1593
  }, async (args) => {
1358
1594
  const resolved = resolveAccount(pool, args.accountId);
1359
1595
  if (isToolError(resolved)) return resolved;
1360
- const accountId = String(resolved.accountId);
1596
+ const accountId = resolved.accountId;
1361
1597
  try {
1362
1598
  const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1363
1599
  const lotSize = num(symbol.lotSize);
1364
1600
  const toLots = (v) => lotSize > 0 ? volumeToLots(num(v), lotSize) : null;
1365
1601
  return ok({
1366
1602
  accountId,
1367
- symbolId: str(symbol.symbolId) || String(args.symbolId),
1603
+ symbolId: str(symbol.symbolId) || args.symbolId,
1368
1604
  digits: num(symbol.digits),
1369
1605
  pipPosition: num(symbol.pipPosition),
1370
1606
  lotSize,
@@ -1385,13 +1621,13 @@ function registerTools(server, pool) {
1385
1621
  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
1622
  inputSchema: {
1387
1623
  accountId: accountIdField,
1388
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
1624
+ symbolId: int64IdField("Symbol id from get_symbols"),
1389
1625
  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
1626
  }
1391
1627
  }, async (args) => {
1392
1628
  const resolved = resolveAccount(pool, args.accountId);
1393
1629
  if (isToolError(resolved)) return resolved;
1394
- const accountId = String(resolved.accountId);
1630
+ const accountId = resolved.accountId;
1395
1631
  try {
1396
1632
  const digits = num((await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId)).digits);
1397
1633
  const quote = {
@@ -1409,10 +1645,10 @@ function registerTools(server, pool) {
1409
1645
  if (message.ask != null) quote.ask = roundToDigits(priceToDecimal(num(message.ask)), digits);
1410
1646
  if (quote.bid != null && quote.ask != null) signalBothSeen();
1411
1647
  }, () => 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`));
1648
+ 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
1649
  return ok({
1414
1650
  accountId,
1415
- symbolId: String(args.symbolId),
1651
+ symbolId: args.symbolId,
1416
1652
  digits,
1417
1653
  bid: quote.bid,
1418
1654
  ask: quote.ask,
@@ -1428,14 +1664,14 @@ function registerTools(server, pool) {
1428
1664
  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
1665
  inputSchema: {
1430
1666
  accountId: accountIdField,
1431
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
1667
+ symbolId: int64IdField("Symbol id from get_symbols"),
1432
1668
  levels: z.coerce.number().int().min(1).max(50).default(10).describe("Max price levels per side to return"),
1433
1669
  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
1670
  }
1435
1671
  }, async (args) => {
1436
1672
  const resolved = resolveAccount(pool, args.accountId);
1437
1673
  if (isToolError(resolved)) return resolved;
1438
- const accountId = String(resolved.accountId);
1674
+ const accountId = resolved.accountId;
1439
1675
  try {
1440
1676
  const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1441
1677
  const digits = num(symbol.digits);
@@ -1461,7 +1697,7 @@ function registerTools(server, pool) {
1461
1697
  }
1462
1698
  for (const deleted of message.deletedQuotes ?? []) book.delete(str(deleted));
1463
1699
  }, () => 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`));
1700
+ 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
1701
  const ladder = (side) => {
1466
1702
  const byPrice = /* @__PURE__ */ new Map();
1467
1703
  for (const quote of book.values()) {
@@ -1482,7 +1718,7 @@ function registerTools(server, pool) {
1482
1718
  const bestAsk = asks.at(0)?.price ?? null;
1483
1719
  return ok({
1484
1720
  accountId,
1485
- symbolId: String(args.symbolId),
1721
+ symbolId: args.symbolId,
1486
1722
  digits,
1487
1723
  bestBid,
1488
1724
  bestAsk,
@@ -1503,15 +1739,15 @@ function registerTools(server, pool) {
1503
1739
  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
1740
  inputSchema: {
1505
1741
  accountId: accountIdField,
1506
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
1742
+ symbolId: int64IdField("Symbol id from get_symbols"),
1507
1743
  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)")
1744
+ from: z.string().max(100).optional().describe("ISO 8601 range start (default: 5 minutes before `to`)"),
1745
+ to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
1510
1746
  }
1511
1747
  }, async (args) => {
1512
1748
  const resolved = resolveAccount(pool, args.accountId);
1513
1749
  if (isToolError(resolved)) return resolved;
1514
- const accountId = String(resolved.accountId);
1750
+ const accountId = resolved.accountId;
1515
1751
  try {
1516
1752
  const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1517
1753
  const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 5 * 6e4;
@@ -1526,7 +1762,7 @@ function registerTools(server, pool) {
1526
1762
  const raw = res.message.tickData ?? [];
1527
1763
  return ok({
1528
1764
  accountId,
1529
- symbolId: String(args.symbolId),
1765
+ symbolId: args.symbolId,
1530
1766
  type: args.type,
1531
1767
  count: raw.length,
1532
1768
  hasMore: Boolean(res.message.hasMore),
@@ -1540,14 +1776,14 @@ function registerTools(server, pool) {
1540
1776
  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
1777
  inputSchema: {
1542
1778
  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)"),
1779
+ from: z.string().max(100).optional().describe("ISO 8601 range start (default: 7 days before `to`)"),
1780
+ to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)"),
1545
1781
  maxRows: z.coerce.number().int().min(1).max(1e3).default(100).describe("Max deals to return")
1546
1782
  }
1547
1783
  }, async (args) => {
1548
1784
  const resolved = resolveAccount(pool, args.accountId);
1549
1785
  if (isToolError(resolved)) return resolved;
1550
- const accountId = String(resolved.accountId);
1786
+ const accountId = resolved.accountId;
1551
1787
  try {
1552
1788
  const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1553
1789
  const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 7 * 864e5;
@@ -1599,7 +1835,7 @@ function registerTools(server, pool) {
1599
1835
  }, async (args) => {
1600
1836
  const resolved = resolveAccount(pool, args.accountId);
1601
1837
  if (isToolError(resolved)) return resolved;
1602
- const accountId = String(resolved.accountId);
1838
+ const accountId = resolved.accountId;
1603
1839
  try {
1604
1840
  const res = await pool.request(accountId, PayloadType.OA_GET_POSITION_UNREALIZED_PNL_REQ, { ctidTraderAccountId: resolved.accountId });
1605
1841
  const moneyDigits = res.message.moneyDigits != null ? num(res.message.moneyDigits) : 2;
@@ -1620,16 +1856,16 @@ function registerTools(server, pool) {
1620
1856
  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
1857
  inputSchema: {
1622
1858
  accountId: accountIdField,
1623
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
1859
+ symbolId: int64IdField("Symbol id from get_symbols"),
1624
1860
  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
1861
  }
1626
1862
  }, async (args) => {
1627
1863
  const resolved = resolveAccount(pool, args.accountId);
1628
1864
  if (isToolError(resolved)) return resolved;
1629
- const accountId = String(resolved.accountId);
1865
+ const accountId = resolved.accountId;
1630
1866
  try {
1631
1867
  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`));
1868
+ if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the margin quote`));
1633
1869
  const res = await pool.request(accountId, PayloadType.OA_EXPECTED_MARGIN_REQ, {
1634
1870
  ctidTraderAccountId: resolved.accountId,
1635
1871
  symbolId: args.symbolId,
@@ -1639,7 +1875,7 @@ function registerTools(server, pool) {
1639
1875
  const margins = res.message.margin ?? [];
1640
1876
  return ok({
1641
1877
  accountId,
1642
- symbolId: String(args.symbolId),
1878
+ symbolId: args.symbolId,
1643
1879
  margins: margins.map((m) => ({
1644
1880
  volumeLots: volumeToLots(num(m.volume), lotSize),
1645
1881
  buyMargin: moneyToDecimal(num(m.buyMargin), moneyDigits),
@@ -1654,13 +1890,13 @@ function registerTools(server, pool) {
1654
1890
  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
1891
  inputSchema: {
1656
1892
  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)")
1893
+ from: z.string().max(100).optional().describe("ISO 8601 range start (default: 30 days before `to`)"),
1894
+ to: z.string().max(100).optional().describe("ISO 8601 range end (default: now)")
1659
1895
  }
1660
1896
  }, async (args) => {
1661
1897
  const resolved = resolveAccount(pool, args.accountId);
1662
1898
  if (isToolError(resolved)) return resolved;
1663
- const accountId = String(resolved.accountId);
1899
+ const accountId = resolved.accountId;
1664
1900
  try {
1665
1901
  const toMs = args.to != null ? parseTimestamp(args.to, "to") : Date.now();
1666
1902
  const fromMs = args.from != null ? parseTimestamp(args.from, "from") : toMs - 30 * 864e5;
@@ -1692,7 +1928,7 @@ function registerTools(server, pool) {
1692
1928
  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
1929
  inputSchema: {
1694
1930
  accountId: accountIdField,
1695
- symbolId: z.coerce.number().int().positive().describe("Symbol id from get_symbols"),
1931
+ symbolId: int64IdField("Symbol id from get_symbols"),
1696
1932
  side: z.enum(["BUY", "SELL"]).describe("Trade side"),
1697
1933
  orderType: z.enum([
1698
1934
  "MARKET",
@@ -1715,17 +1951,19 @@ function registerTools(server, pool) {
1715
1951
  "IMMEDIATE_OR_CANCEL",
1716
1952
  "FILL_OR_KILL"
1717
1953
  ]).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")
1954
+ expiresAt: z.string().max(100).optional().describe("ISO 8601 expiry — required with (and only valid with) timeInForce GOOD_TILL_DATE"),
1955
+ label: z.string().max(50).optional().describe("Optional client label for the order (max 50 characters)")
1720
1956
  }
1721
1957
  }, async (args) => {
1722
1958
  const resolved = resolveAccount(pool, args.accountId);
1723
1959
  if (isToolError(resolved)) return resolved;
1724
- const accountId = String(resolved.accountId);
1960
+ const accountId = resolved.accountId;
1725
1961
  try {
1726
1962
  if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
1727
1963
  if ((args.orderType === "STOP" || args.orderType === "STOP_LIMIT") && args.stopPrice == null) return fail(new CTraderError("STOP_PRICE_REQUIRED", `${args.orderType} orders require stopPrice`));
1728
1964
  if (args.orderType === "STOP_LIMIT" && args.slippageInPoints == null) return fail(new CTraderError("SLIPPAGE_REQUIRED", "STOP_LIMIT orders require slippageInPoints"));
1965
+ if (args.orderType === "STOP_LIMIT" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP_LIMIT orders use stopPrice + slippageInPoints, not limitPrice"));
1966
+ if (args.orderType !== "STOP_LIMIT" && args.slippageInPoints != null) return fail(new CTraderError("SLIPPAGE_NOT_ALLOWED", "slippageInPoints is only valid for STOP_LIMIT orders"));
1729
1967
  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
1968
  if (args.orderType === "LIMIT" && args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "LIMIT orders take no stopPrice"));
1731
1969
  if (args.orderType === "STOP" && args.limitPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "STOP orders take no limitPrice"));
@@ -1737,9 +1975,18 @@ function registerTools(server, pool) {
1737
1975
  }
1738
1976
  if (args.timeInForce === "GOOD_TILL_DATE" && args.expiresAt == null) return fail(new CTraderError("EXPIRATION_REQUIRED", "timeInForce GOOD_TILL_DATE requires expiresAt"));
1739
1977
  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"));
1978
+ let expirationTimestamp;
1979
+ if (args.expiresAt != null) {
1980
+ expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
1981
+ if (expirationTimestamp <= Date.now()) return fail(new CTraderError("EXPIRATION_IN_PAST", "expiresAt must be in the future"));
1982
+ }
1983
+ const relativeStopLoss = args.stopLossDistance != null ? Math.round(args.stopLossDistance * PRICE_SCALE) : void 0;
1984
+ const relativeTakeProfit = args.takeProfitDistance != null ? Math.round(args.takeProfitDistance * PRICE_SCALE) : void 0;
1985
+ 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"));
1986
+ 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
1987
  const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1741
1988
  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`));
1989
+ if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${args.symbolId} has no lotSize; cannot size the order`));
1743
1990
  const volume = lotsToVolume(args.volumeLots, lotSize);
1744
1991
  const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
1745
1992
  if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
@@ -1755,13 +2002,13 @@ function registerTools(server, pool) {
1755
2002
  if (args.slippageInPoints != null) payload.slippageInPoints = args.slippageInPoints;
1756
2003
  if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1757
2004
  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);
2005
+ if (relativeStopLoss != null) payload.relativeStopLoss = relativeStopLoss;
2006
+ if (relativeTakeProfit != null) payload.relativeTakeProfit = relativeTakeProfit;
1760
2007
  if (args.trailingStopLoss != null) payload.trailingStopLoss = args.trailingStopLoss;
1761
2008
  if (args.timeInForce != null) payload.timeInForce = TIME_IN_FORCE[args.timeInForce];
1762
- if (args.expiresAt != null) payload.expirationTimestamp = parseTimestamp(args.expiresAt, "expiresAt");
2009
+ if (expirationTimestamp != null) payload.expirationTimestamp = expirationTimestamp;
1763
2010
  if (args.label != null) payload.label = args.label;
1764
- const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
2011
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload), PLACE_ORDER_SUCCESS_TYPES);
1765
2012
  return ok({
1766
2013
  accountId,
1767
2014
  executionType,
@@ -1777,8 +2024,8 @@ function registerTools(server, pool) {
1777
2024
  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
2025
  inputSchema: {
1779
2026
  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"),
2027
+ orderId: int64IdField("Pending order id (from get_orders) to amend").optional(),
2028
+ positionId: int64IdField("Open position id (from get_positions) to set SL/TP on").optional(),
1782
2029
  limitPrice: z.coerce.number().positive().optional().describe("New limit price (pending order only)"),
1783
2030
  stopPrice: z.coerce.number().positive().optional().describe("New stop price (pending order only)"),
1784
2031
  stopLoss: z.coerce.number().positive().optional().describe("New absolute stop-loss price"),
@@ -1787,21 +2034,23 @@ function registerTools(server, pool) {
1787
2034
  }, async (args) => {
1788
2035
  const resolved = resolveAccount(pool, args.accountId);
1789
2036
  if (isToolError(resolved)) return resolved;
1790
- const accountId = String(resolved.accountId);
2037
+ const accountId = resolved.accountId;
1791
2038
  try {
1792
2039
  if (args.orderId == null === (args.positionId == null)) return fail(new CTraderError("INVALID_TARGET", "Provide exactly one of orderId or positionId"));
2040
+ 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
2041
  if (args.positionId != null) {
2042
+ if (args.limitPrice != null || args.stopPrice != null) return fail(new CTraderError("PRICE_NOT_ALLOWED", "Position protection changes accept only stopLoss/takeProfit"));
1794
2043
  const payload = {
1795
2044
  ctidTraderAccountId: resolved.accountId,
1796
2045
  positionId: args.positionId
1797
2046
  };
1798
2047
  if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1799
2048
  if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1800
- const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload));
2049
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload), AMEND_SUCCESS_TYPES);
1801
2050
  return ok({
1802
2051
  accountId,
1803
2052
  executionType,
1804
- positionId: String(args.positionId),
2053
+ positionId: args.positionId,
1805
2054
  execution: summary
1806
2055
  });
1807
2056
  }
@@ -1813,11 +2062,11 @@ function registerTools(server, pool) {
1813
2062
  if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
1814
2063
  if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1815
2064
  if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1816
- const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload));
2065
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload), AMEND_SUCCESS_TYPES);
1817
2066
  return ok({
1818
2067
  accountId,
1819
2068
  executionType,
1820
- orderId: String(args.orderId),
2069
+ orderId: args.orderId,
1821
2070
  execution: summary
1822
2071
  });
1823
2072
  } catch (err) {
@@ -1828,36 +2077,41 @@ function registerTools(server, pool) {
1828
2077
  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
2078
  inputSchema: {
1830
2079
  accountId: accountIdField,
1831
- positionId: z.coerce.number().int().positive().describe("Position id from get_positions"),
2080
+ positionId: int64IdField("Position id from get_positions"),
1832
2081
  volumeLots: z.coerce.number().positive().optional().describe("Lots to close; omit to close the whole position")
1833
2082
  }
1834
2083
  }, async (args) => {
1835
2084
  const resolved = resolveAccount(pool, args.accountId);
1836
2085
  if (isToolError(resolved)) return resolved;
1837
- const accountId = String(resolved.accountId);
2086
+ const accountId = resolved.accountId;
1838
2087
  try {
1839
2088
  const pos = ((await pool.request(accountId, PayloadType.OA_RECONCILE_REQ, {
1840
2089
  ctidTraderAccountId: resolved.accountId,
1841
2090
  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)}`));
2091
+ })).message.position ?? []).find((p) => str(p.positionId) === args.positionId);
2092
+ if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${args.positionId}`));
1844
2093
  const td = pos.tradeData ?? {};
1845
2094
  const fullVolume = num(td.volume);
2095
+ if (!Number.isSafeInteger(fullVolume) || fullVolume <= 0) return fail(new CTraderError("INVALID_POSITION_VOLUME", `Position ${args.positionId} has invalid broker volume ${String(fullVolume)}`));
1846
2096
  let volume = fullVolume;
1847
2097
  if (args.volumeLots != null) {
1848
- const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, num(td.symbolId));
2098
+ const positionSymbolId = str(td.symbolId);
2099
+ if (!isCanonicalInt64(positionSymbolId)) return fail(new CTraderError("INVALID_POSITION_SYMBOL", `Position ${args.positionId} has an invalid symbol id`));
2100
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, positionSymbolId);
1849
2101
  volume = lotsToVolume(args.volumeLots, num(symbol.lotSize));
2102
+ const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
2103
+ if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
1850
2104
  if (volume > fullVolume) return fail(new CTraderError("VOLUME_EXCEEDS_POSITION", `Requested ${String(volume)} exceeds position volume ${String(fullVolume)}`));
1851
2105
  }
1852
2106
  const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CLOSE_POSITION_REQ, {
1853
2107
  ctidTraderAccountId: resolved.accountId,
1854
2108
  positionId: args.positionId,
1855
2109
  volume
1856
- }));
2110
+ }), CLOSE_SUCCESS_TYPES);
1857
2111
  return ok({
1858
2112
  accountId,
1859
2113
  executionType,
1860
- positionId: String(args.positionId),
2114
+ positionId: args.positionId,
1861
2115
  volume,
1862
2116
  execution: summary
1863
2117
  });
@@ -1869,21 +2123,21 @@ function registerTools(server, pool) {
1869
2123
  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
2124
  inputSchema: {
1871
2125
  accountId: accountIdField,
1872
- orderId: z.coerce.number().int().positive().describe("Pending order id from get_orders")
2126
+ orderId: int64IdField("Pending order id from get_orders")
1873
2127
  }
1874
2128
  }, async (args) => {
1875
2129
  const resolved = resolveAccount(pool, args.accountId);
1876
2130
  if (isToolError(resolved)) return resolved;
1877
- const accountId = String(resolved.accountId);
2131
+ const accountId = resolved.accountId;
1878
2132
  try {
1879
2133
  const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CANCEL_ORDER_REQ, {
1880
2134
  ctidTraderAccountId: resolved.accountId,
1881
2135
  orderId: args.orderId
1882
- }), new Set([5]));
2136
+ }), CANCEL_SUCCESS_TYPES);
1883
2137
  return ok({
1884
2138
  accountId,
1885
2139
  executionType,
1886
- orderId: String(args.orderId),
2140
+ orderId: args.orderId,
1887
2141
  execution: summary
1888
2142
  });
1889
2143
  } catch (err) {
@@ -1907,17 +2161,16 @@ function summariseExecution(message) {
1907
2161
  /**
1908
2162
  * Turn a write-tool reply into a definite outcome. A cTrader order op is
1909
2163
  * 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.
2164
+ * we can't confirm the write landed. Each operation passes an explicit
2165
+ * allowlist of execution types that prove its intended outcome; missing,
2166
+ * unknown, or merely unrelated execution events fail closed.
1914
2167
  */
1915
- function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
2168
+ function assertExecution(res, allowedTypes) {
1916
2169
  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
2170
  const summary = summariseExecution(res.message);
1918
2171
  const code = res.message.executionType != null ? num(res.message.executionType) : null;
1919
2172
  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})`, {
2173
+ 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
2174
  orderId: str(summary.orderId) || void 0,
1922
2175
  positionId: str(summary.positionId) || void 0
1923
2176
  });
@@ -1955,6 +2208,7 @@ function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
1955
2208
  * account it names (see client.ts). No account is ever chosen implicitly when
1956
2209
  * several are connected — the tools require an explicit `accountId` in that case.
1957
2210
  */
2211
+ const packageVersion = createRequire(import.meta.url)("../package.json").version;
1958
2212
  function log(msg) {
1959
2213
  process.stderr.write(`[ctrader-mcp] ${msg}\n`);
1960
2214
  }
@@ -1978,7 +2232,7 @@ async function main() {
1978
2232
  const pool = new CTraderPool(registry);
1979
2233
  const server = new McpServer({
1980
2234
  name: "ctrader-mcp-server",
1981
- version: "0.0.1"
2235
+ version: packageVersion
1982
2236
  });
1983
2237
  registerTools(server, pool);
1984
2238
  const shutdown = () => {