@alfe.ai/ctrader-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,1311 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { resolveConfig } from "@alfe.ai/config";
6
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
7
+ import { randomUUID } from "node:crypto";
8
+ import * as tls from "node:tls";
9
+ import { fileURLToPath } from "node:url";
10
+ import { dirname, join } from "node:path";
11
+ import protobuf from "protobufjs";
12
+ import { z } from "zod";
13
+ //#region src/config.ts
14
+ /**
15
+ * cTrader MCP server configuration.
16
+ *
17
+ * Like the other Alfe MCP servers, this one self-fetches its credentials at
18
+ * startup via `@alfe.ai/agent-api-client`. The primary path is the
19
+ * multi-account accessor `getCTraderAccounts()`: a single cTrader OAuth grant
20
+ * covers ALL of the user's trading accounts on one shared `accessToken`, with
21
+ * the SST-global app credentials (`clientId`/`clientSecret`) hoisted to the top
22
+ * level. Only the `ctidTraderAccountId` and the protobuf socket `host` (live vs
23
+ * demo) differ per account. We build an account *registry* from that set here
24
+ * — one entry per account, grouped by host so a distinct socket serves each
25
+ * distinct host (live vs demo). The cTrader Open API app-auth handshake needs
26
+ * the raw client id/secret + access token on the box, which is why the connect
27
+ * endpoint returns the full set rather than brokering each socket request.
28
+ */
29
+ /** cTrader Open API TLS endpoints. Port is always 5035. */
30
+ const CTRADER_LIVE_HOST = "live.ctraderapi.com";
31
+ const CTRADER_DEMO_HOST = "demo.ctraderapi.com";
32
+ const CTRADER_PORT = 5035;
33
+ /** Raised when the fetched credential set is missing or malformed. */
34
+ var ConfigError = class extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "ConfigError";
38
+ }
39
+ };
40
+ /**
41
+ * Resolve the TLS host from a live/demo hint.
42
+ *
43
+ * Accepts either a full hostname (`live.ctraderapi.com` /
44
+ * `demo.ctraderapi.com`) or a short alias (`live` / `demo`). Defaults to
45
+ * demo when unset — demo is the safe default because full-trading orders
46
+ * move real money on a live account.
47
+ */
48
+ function resolveHost(raw) {
49
+ const value = (raw ?? "").trim().toLowerCase();
50
+ if (value === "" || value === "demo") return CTRADER_DEMO_HOST;
51
+ if (value === "live") return CTRADER_LIVE_HOST;
52
+ if (value === "live.ctraderapi.com" || value === "demo.ctraderapi.com") return value;
53
+ throw new ConfigError(`Invalid cTrader host "${raw ?? ""}". Expected "live", "demo", "${CTRADER_LIVE_HOST}", or "${CTRADER_DEMO_HOST}".`);
54
+ }
55
+ /**
56
+ * Assemble the account registry from the multi-account credential set returned
57
+ * by `getCTraderAccounts()`. This is the primary startup path.
58
+ *
59
+ * Throws `ConfigError` (→ MCP startup failure, fail closed) when:
60
+ * - `accounts` is empty (no cTrader Connection — there is nothing to trade);
61
+ * - the shared app credentials (`clientId`/`clientSecret`/`accessToken`) are
62
+ * missing (every account needs them to authenticate);
63
+ * - after validating each account, no valid account remains.
64
+ *
65
+ * Individual accounts with an unparseable `ctidTraderAccountId` are skipped
66
+ * (logged by the caller), but if that leaves the registry empty we fail closed
67
+ * rather than start a server that can trade on nothing.
68
+ */
69
+ function buildRegistry(creds) {
70
+ const clientId = creds.clientId.trim();
71
+ const clientSecret = creds.clientSecret.trim();
72
+ const accessToken = creds.accessToken.trim();
73
+ if (creds.accounts.length === 0) throw new ConfigError("No cTrader trading accounts are authorized for this agent. Connect a cTrader account (OAuth) for this agent, then retry.");
74
+ const missing = [];
75
+ if (!clientId) missing.push("clientId");
76
+ if (!clientSecret) missing.push("clientSecret");
77
+ if (!accessToken) missing.push("accessToken");
78
+ if (missing.length > 0) throw new ConfigError(`The cTrader connection is missing shared credential field(s): ${missing.join(", ")}. Connect a cTrader account (OAuth) for this agent, then retry.`);
79
+ const registry = /* @__PURE__ */ new Map();
80
+ for (const account of creds.accounts) {
81
+ const idRaw = account.ctidTraderAccountId.trim();
82
+ const accountId = Number(idRaw);
83
+ if (!idRaw || !Number.isInteger(accountId) || accountId <= 0) continue;
84
+ const host = resolveHost(account.host);
85
+ registry.set(idRaw, {
86
+ clientId,
87
+ clientSecret,
88
+ accessToken,
89
+ accountId,
90
+ host,
91
+ isLive: account.isLive,
92
+ ...account.brokerName != null ? { brokerName: account.brokerName } : {},
93
+ ...account.accountNumber != null ? { accountNumber: account.accountNumber } : {}
94
+ });
95
+ }
96
+ if (registry.size === 0) throw new ConfigError("Every cTrader account returned had an unusable ctidTraderAccountId; cannot build a trading registry. Re-connect the cTrader account (OAuth).");
97
+ return registry;
98
+ }
99
+ //#endregion
100
+ //#region src/payload-types.ts
101
+ /**
102
+ * cTrader Open API payloadType numbers.
103
+ *
104
+ * Every message on the wire is wrapped in a `ProtoMessage` envelope whose
105
+ * `payloadType` field is the numeric enum value below. These are transcribed
106
+ * verbatim from Spotware's `OpenApiCommonModelMessages.proto` (common layer)
107
+ * and `OpenApiModelMessages.proto` (OA layer). They are load-bearing — a wrong
108
+ * number silently routes a message to the wrong decoder.
109
+ *
110
+ * Source: github.com/spotware/openapi-proto-messages
111
+ */
112
+ const PayloadType = {
113
+ PROTO_MESSAGE: 5,
114
+ ERROR_RES: 50,
115
+ HEARTBEAT_EVENT: 51,
116
+ OA_APPLICATION_AUTH_REQ: 2100,
117
+ OA_APPLICATION_AUTH_RES: 2101,
118
+ OA_ACCOUNT_AUTH_REQ: 2102,
119
+ OA_ACCOUNT_AUTH_RES: 2103,
120
+ OA_NEW_ORDER_REQ: 2106,
121
+ OA_CANCEL_ORDER_REQ: 2108,
122
+ OA_AMEND_ORDER_REQ: 2109,
123
+ OA_AMEND_POSITION_SLTP_REQ: 2110,
124
+ OA_CLOSE_POSITION_REQ: 2111,
125
+ OA_SYMBOLS_LIST_REQ: 2114,
126
+ OA_SYMBOLS_LIST_RES: 2115,
127
+ OA_SYMBOL_BY_ID_REQ: 2116,
128
+ OA_SYMBOL_BY_ID_RES: 2117,
129
+ OA_TRADER_REQ: 2121,
130
+ OA_TRADER_RES: 2122,
131
+ OA_RECONCILE_REQ: 2124,
132
+ OA_RECONCILE_RES: 2125,
133
+ OA_EXECUTION_EVENT: 2126,
134
+ OA_SUBSCRIBE_SPOTS_REQ: 2127,
135
+ OA_SUBSCRIBE_SPOTS_RES: 2128,
136
+ OA_SPOT_EVENT: 2131,
137
+ OA_ORDER_ERROR_EVENT: 2132,
138
+ OA_GET_TRENDBARS_REQ: 2137,
139
+ OA_GET_TRENDBARS_RES: 2138,
140
+ OA_ERROR_RES: 2142,
141
+ OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_REQ: 2149,
142
+ OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES: 2150
143
+ };
144
+ /**
145
+ * Map each request payloadType to the fully-qualified proto message name used
146
+ * to encode its payload. Keeping this table beside the numbers lets the client
147
+ * encode/route generically instead of hand-writing an encode call per op.
148
+ */
149
+ const REQUEST_MESSAGE = {
150
+ [PayloadType.HEARTBEAT_EVENT]: "ctrader.ProtoHeartbeatEvent",
151
+ [PayloadType.OA_APPLICATION_AUTH_REQ]: "ctrader.ProtoOAApplicationAuthReq",
152
+ [PayloadType.OA_ACCOUNT_AUTH_REQ]: "ctrader.ProtoOAAccountAuthReq",
153
+ [PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_REQ]: "ctrader.ProtoOAGetAccountListByAccessTokenReq",
154
+ [PayloadType.OA_TRADER_REQ]: "ctrader.ProtoOATraderReq",
155
+ [PayloadType.OA_RECONCILE_REQ]: "ctrader.ProtoOAReconcileReq",
156
+ [PayloadType.OA_SYMBOLS_LIST_REQ]: "ctrader.ProtoOASymbolsListReq",
157
+ [PayloadType.OA_SYMBOL_BY_ID_REQ]: "ctrader.ProtoOASymbolByIdReq",
158
+ [PayloadType.OA_GET_TRENDBARS_REQ]: "ctrader.ProtoOAGetTrendbarsReq",
159
+ [PayloadType.OA_SUBSCRIBE_SPOTS_REQ]: "ctrader.ProtoOASubscribeSpotsReq",
160
+ [PayloadType.OA_NEW_ORDER_REQ]: "ctrader.ProtoOANewOrderReq",
161
+ [PayloadType.OA_AMEND_ORDER_REQ]: "ctrader.ProtoOAAmendOrderReq",
162
+ [PayloadType.OA_AMEND_POSITION_SLTP_REQ]: "ctrader.ProtoOAAmendPositionSLTPReq",
163
+ [PayloadType.OA_CLOSE_POSITION_REQ]: "ctrader.ProtoOAClosePositionReq",
164
+ [PayloadType.OA_CANCEL_ORDER_REQ]: "ctrader.ProtoOACancelOrderReq"
165
+ };
166
+ /** Map each response/event payloadType to the proto message name to decode it. */
167
+ const RESPONSE_MESSAGE = {
168
+ [PayloadType.ERROR_RES]: "ctrader.ProtoErrorRes",
169
+ [PayloadType.HEARTBEAT_EVENT]: "ctrader.ProtoHeartbeatEvent",
170
+ [PayloadType.OA_APPLICATION_AUTH_RES]: "ctrader.ProtoOAApplicationAuthRes",
171
+ [PayloadType.OA_ACCOUNT_AUTH_RES]: "ctrader.ProtoOAAccountAuthRes",
172
+ [PayloadType.OA_GET_ACCOUNT_LIST_BY_ACCESS_TOKEN_RES]: "ctrader.ProtoOAGetAccountListByAccessTokenRes",
173
+ [PayloadType.OA_TRADER_RES]: "ctrader.ProtoOATraderRes",
174
+ [PayloadType.OA_RECONCILE_RES]: "ctrader.ProtoOAReconcileRes",
175
+ [PayloadType.OA_SYMBOLS_LIST_RES]: "ctrader.ProtoOASymbolsListRes",
176
+ [PayloadType.OA_SYMBOL_BY_ID_RES]: "ctrader.ProtoOASymbolByIdRes",
177
+ [PayloadType.OA_GET_TRENDBARS_RES]: "ctrader.ProtoOAGetTrendbarsRes",
178
+ [PayloadType.OA_SUBSCRIBE_SPOTS_RES]: "ctrader.ProtoOASubscribeSpotsRes",
179
+ [PayloadType.OA_SPOT_EVENT]: "ctrader.ProtoOASpotEvent",
180
+ [PayloadType.OA_EXECUTION_EVENT]: "ctrader.ProtoOAExecutionEvent",
181
+ [PayloadType.OA_ORDER_ERROR_EVENT]: "ctrader.ProtoOAOrderErrorEvent",
182
+ [PayloadType.OA_ERROR_RES]: "ctrader.ProtoOAErrorRes"
183
+ };
184
+ //#endregion
185
+ //#region src/proto.ts
186
+ /**
187
+ * protobuf schema loading + the length-prefixed `ProtoMessage` wire codec.
188
+ *
189
+ * Foundation decision: we load Spotware's message subset from a vendored
190
+ * `.proto` (see `proto/ctrader.proto`) with `protobufjs` at runtime, rather
191
+ * than depending on the abandoned `@reiryoku/ctrader-layer` npm package
192
+ * (last published 2022, pins protobufjs@5 + axios@0.21). See README.
193
+ *
194
+ * Wire framing (verified against Spotware OpenApiPy `Int32StringReceiver`):
195
+ * [ 4-byte big-endian uint32 length ][ serialized ProtoMessage bytes ]
196
+ */
197
+ /** Decode an envelope's payload using a given payloadType→message-name table. */
198
+ function decodeWith(root, envelopeBytes, table) {
199
+ const env = root.lookupType("ctrader.ProtoMessage").decode(envelopeBytes);
200
+ const messageName = table[env.payloadType];
201
+ let message = {};
202
+ if (messageName && env.payload) {
203
+ const InnerType = root.lookupType(messageName);
204
+ message = InnerType.toObject(InnerType.decode(env.payload), {
205
+ longs: String,
206
+ enums: Number,
207
+ defaults: false
208
+ });
209
+ }
210
+ return {
211
+ payloadType: env.payloadType,
212
+ clientMsgId: env.clientMsgId,
213
+ message
214
+ };
215
+ }
216
+ createRequire(import.meta.url);
217
+ /**
218
+ * Resolve the vendored `.proto`. It ships in the package `files` array at
219
+ * `proto/ctrader.proto`; the compiled server lives at `dist/server.js`, so the
220
+ * proto is one directory up from `dist/`. During tests we run from `src/`, so
221
+ * the same `../proto` relative hop resolves correctly there too.
222
+ */
223
+ function resolveProtoPath() {
224
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "proto", "ctrader.proto");
225
+ }
226
+ let cachedRoot = null;
227
+ /** Load (and memoise) the protobuf root from the vendored schema. */
228
+ function loadRoot(protoPath = resolveProtoPath()) {
229
+ cachedRoot ??= protobuf.loadSync(protoPath);
230
+ return cachedRoot;
231
+ }
232
+ /**
233
+ * Encode an outbound request into a fully-framed buffer:
234
+ * length prefix + ProtoMessage(payloadType, payload, clientMsgId).
235
+ */
236
+ function encodeRequest(root, payloadType, payload, clientMsgId) {
237
+ const messageName = REQUEST_MESSAGE[payloadType];
238
+ if (!messageName) throw new Error(`No request message registered for payloadType ${String(payloadType)}`);
239
+ const InnerType = root.lookupType(messageName);
240
+ const innerErr = InnerType.verify(payload);
241
+ if (innerErr) throw new Error(`Invalid ${messageName} payload: ${innerErr}`);
242
+ const innerBytes = InnerType.encode(InnerType.create(payload)).finish();
243
+ const Envelope = root.lookupType("ctrader.ProtoMessage");
244
+ const envelopeBytes = Envelope.encode(Envelope.create({
245
+ payloadType,
246
+ payload: innerBytes,
247
+ clientMsgId
248
+ })).finish();
249
+ const frame = Buffer.allocUnsafe(4 + envelopeBytes.length);
250
+ frame.writeUInt32BE(envelopeBytes.length, 0);
251
+ Buffer.from(envelopeBytes).copy(frame, 4);
252
+ return frame;
253
+ }
254
+ /** Decode a single ProtoMessage envelope (already de-framed) into its payload. */
255
+ function decodeEnvelope(root, envelopeBytes) {
256
+ return decodeWith(root, envelopeBytes, RESPONSE_MESSAGE);
257
+ }
258
+ /**
259
+ * Incremental frame de-chunker for a TLS byte stream.
260
+ *
261
+ * `push` appends received bytes and returns every complete envelope now
262
+ * available (a single TCP read may contain 0, 1, or many frames, or a partial
263
+ * frame that stays buffered until the rest arrives).
264
+ */
265
+ var FrameParser = class {
266
+ buffer = Buffer.alloc(0);
267
+ push(chunk) {
268
+ this.buffer = Buffer.concat([this.buffer, chunk]);
269
+ const frames = [];
270
+ for (;;) {
271
+ if (this.buffer.length < 4) break;
272
+ const length = this.buffer.readUInt32BE(0);
273
+ if (this.buffer.length < 4 + length) break;
274
+ frames.push(this.buffer.subarray(4, 4 + length));
275
+ this.buffer = this.buffer.subarray(4 + length);
276
+ }
277
+ return frames;
278
+ }
279
+ /** Bytes currently buffered awaiting completion (diagnostics / tests). */
280
+ get pending() {
281
+ return this.buffer.length;
282
+ }
283
+ };
284
+ //#endregion
285
+ //#region src/convert.ts
286
+ /**
287
+ * Unit conversions for the cTrader Open API.
288
+ *
289
+ * cTrader speaks in integer minor-units to avoid floats on the wire:
290
+ * - money (balance) is scaled by `moneyDigits` (default 2 → cents)
291
+ * - volume is in "centi-units" (0.01 of the base asset); `symbol.lotSize`
292
+ * is already the centi-unit volume of one lot
293
+ * - trendbar / spot prices are integers scaled by 10^5 (relative-price units)
294
+ *
295
+ * These are pure and unit-tested — the risky arithmetic lives here, not
296
+ * scattered through the tools.
297
+ */
298
+ /**
299
+ * Safely stringify an untyped wire value (protobufjs returns int64 fields as
300
+ * strings via `longs: String`, numbers otherwise). Objects — which should
301
+ * never appear on scalar fields — collapse to `""` rather than
302
+ * `[object Object]`, keeping the JSON output clean.
303
+ */
304
+ function str(v) {
305
+ if (v == null) return "";
306
+ if (typeof v === "string") return v;
307
+ if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") return String(v);
308
+ return "";
309
+ }
310
+ /**
311
+ * Convert an integer money value to its real currency figure.
312
+ * `moneyDigits` is optional on the wire; default 2 (legacy cents behaviour).
313
+ */
314
+ function moneyToDecimal(raw, moneyDigits = 2) {
315
+ return raw / 10 ** moneyDigits;
316
+ }
317
+ /**
318
+ * Convert a lot count to protocol volume (centi-units) for an order.
319
+ * `lotSize` comes from `ProtoOASymbol.lotSize` and is already in centi-units,
320
+ * so `volume = lots * lotSize`. Do NOT hardcode 100000 — lotSize varies by
321
+ * symbol.
322
+ */
323
+ function lotsToVolume(lots, lotSize) {
324
+ return Math.round(lots * lotSize);
325
+ }
326
+ /** Inverse of {@link lotsToVolume}: protocol volume → lot count. */
327
+ function volumeToLots(volume, lotSize) {
328
+ if (lotSize === 0) return 0;
329
+ return volume / lotSize;
330
+ }
331
+ /**
332
+ * Validate an order volume against a symbol's min / max / step, all of which
333
+ * are in the same centi-unit space. Returns an error string, or null if valid.
334
+ */
335
+ function validateVolume(volume, minVolume, maxVolume, stepVolume) {
336
+ if (volume < minVolume) return `volume ${String(volume)} is below the symbol minimum ${String(minVolume)}`;
337
+ if (maxVolume > 0 && volume > maxVolume) return `volume ${String(volume)} exceeds the symbol maximum ${String(maxVolume)}`;
338
+ 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)})`;
339
+ return null;
340
+ }
341
+ /** cTrader trendbar/spot integer prices are scaled by 10^5. */
342
+ const PRICE_SCALE = 1e5;
343
+ /** Convert a raw wire price integer to a real price. */
344
+ function priceToDecimal(raw) {
345
+ return raw / PRICE_SCALE;
346
+ }
347
+ /**
348
+ * Reconstruct a trendbar's OHLC from its delta encoding. cTrader stores the
349
+ * bar `low` absolutely and open/high/close as unsigned deltas above the low.
350
+ */
351
+ function decodeTrendbar(bar) {
352
+ const low = Number(bar.low ?? 0);
353
+ const open = low + Number(bar.deltaOpen ?? 0);
354
+ const high = low + Number(bar.deltaHigh ?? 0);
355
+ const close = low + Number(bar.deltaClose ?? 0);
356
+ const minutes = Number(bar.utcTimestampInMinutes ?? 0);
357
+ return {
358
+ open: priceToDecimal(open),
359
+ high: priceToDecimal(high),
360
+ low: priceToDecimal(low),
361
+ close: priceToDecimal(close),
362
+ volume: Number(bar.volume ?? 0),
363
+ timestamp: (/* @__PURE__ */ new Date(minutes * 6e4)).toISOString()
364
+ };
365
+ }
366
+ //#endregion
367
+ //#region src/client.ts
368
+ /**
369
+ * cTrader Open API client: manages the TLS socket lifecycle, the
370
+ * application + account auth handshake, request/response correlation, the
371
+ * 10-second heartbeat, reconnect-on-drop, and cTrader-error mapping.
372
+ *
373
+ * ── Multi-account model ──
374
+ * A single cTrader OAuth grant covers ALL of the user's trading accounts on one
375
+ * shared access token; only the `ctidTraderAccountId` and the socket `host`
376
+ * (live vs demo) differ per account. But one TLS socket dials exactly ONE host.
377
+ * So the topology is:
378
+ *
379
+ * CTraderPool ── owns the account registry, one HostSocket per DISTINCT host
380
+ * │
381
+ * ├─ HostSocket(live.ctraderapi.com) ── app-auth once; account-auth each
382
+ * │ live account on demand
383
+ * └─ HostSocket(demo.ctraderapi.com) ── app-auth once; account-auth each
384
+ * demo account on demand
385
+ *
386
+ * `CTraderPool.request(accountId, ...)` resolves the account → its host socket,
387
+ * ensures that account is account-authed on that socket, then sends the request
388
+ * carrying the account's `ctidTraderAccountId`. Routing by account id → host is
389
+ * the core money-safety property: a demo account MUST NOT be served by the live
390
+ * socket and vice-versa.
391
+ *
392
+ * The transport is injectable (`connect` factory) so the request/response
393
+ * plumbing and handshake can be unit-tested against a mock duplex without any
394
+ * real network. Production uses `tlsConnect` (node `tls.connect`).
395
+ */
396
+ /** Error carrying a cTrader `errorCode` so tools can surface it verbatim. */
397
+ var CTraderError = class extends Error {
398
+ errorCode;
399
+ /** Present when the reject came back tied to a specific order/position. */
400
+ orderId;
401
+ positionId;
402
+ constructor(errorCode, description, refs) {
403
+ super(description ? `${errorCode}: ${description}` : errorCode);
404
+ this.name = "CTraderError";
405
+ this.errorCode = errorCode;
406
+ if (refs?.orderId != null) this.orderId = refs.orderId;
407
+ if (refs?.positionId != null) this.positionId = refs.positionId;
408
+ }
409
+ };
410
+ const HEARTBEAT_INTERVAL_MS = 1e4;
411
+ const REQUEST_TIMEOUT_MS = 2e4;
412
+ const RECONNECT_BASE_MS = 1e3;
413
+ const RECONNECT_MAX_MS = 3e4;
414
+ function log$1(msg) {
415
+ process.stderr.write(`[ctrader-mcp] ${msg}\n`);
416
+ }
417
+ /** Production TLS transport. */
418
+ const tlsConnect = (host, port) => new Promise((resolve, reject) => {
419
+ const socket = tls.connect({
420
+ host,
421
+ port,
422
+ servername: host
423
+ }, () => {
424
+ resolve(socket);
425
+ });
426
+ socket.once("error", reject);
427
+ });
428
+ /**
429
+ * A single authenticated socket to ONE cTrader host (live or demo).
430
+ *
431
+ * On `start()` it opens the socket and runs the app-auth handshake once. Each
432
+ * account that will be traded on this host is account-authed lazily via
433
+ * `authenticateAccount()` (deduped — an account is only account-authed once per
434
+ * live socket, and re-authed after a reconnect). `request()` carries an
435
+ * explicit `ctidTraderAccountId` so a single socket can serve several accounts
436
+ * on the same host.
437
+ */
438
+ var HostSocket = class {
439
+ host;
440
+ clientId;
441
+ clientSecret;
442
+ connectFn;
443
+ root;
444
+ conn = null;
445
+ parser = new FrameParser();
446
+ pending = /* @__PURE__ */ new Map();
447
+ heartbeatTimer = null;
448
+ reconnectAttempts = 0;
449
+ closing = false;
450
+ connectPromise = null;
451
+ /** ctidTraderAccountId → accessToken used to account-auth it on this socket. */
452
+ authedAccounts = /* @__PURE__ */ new Map();
453
+ /** Serializes account-auth so concurrent tool calls don't double-auth. */
454
+ accountAuthPromises = /* @__PURE__ */ new Map();
455
+ constructor(host, clientId, clientSecret, connectFn = tlsConnect, root = loadRoot()) {
456
+ this.host = host;
457
+ this.clientId = clientId;
458
+ this.clientSecret = clientSecret;
459
+ this.connectFn = connectFn;
460
+ this.root = root;
461
+ }
462
+ /** Connect the socket and run the app-auth handshake. Idempotent. */
463
+ async start() {
464
+ this.connectPromise ??= this.doStart();
465
+ return this.connectPromise;
466
+ }
467
+ async doStart() {
468
+ this.conn = await this.connectFn(this.host, CTRADER_PORT);
469
+ this.wireConnection(this.conn);
470
+ await this.appAuth();
471
+ this.startHeartbeat();
472
+ log$1(`Connected + app-authenticated to ${this.host}`);
473
+ }
474
+ /**
475
+ * Ensure `accountId` is account-authed on this socket. Deduped: an account is
476
+ * account-authed at most once per live socket. Concurrent callers share the
477
+ * same in-flight auth promise.
478
+ */
479
+ async authenticateAccount(accountId, accessToken) {
480
+ await this.start();
481
+ if (this.authedAccounts.get(accountId) === accessToken) return;
482
+ let inFlight = this.accountAuthPromises.get(accountId);
483
+ if (!inFlight) {
484
+ inFlight = this.request(PayloadType.OA_ACCOUNT_AUTH_REQ, {
485
+ ctidTraderAccountId: accountId,
486
+ accessToken
487
+ }).then(() => {
488
+ this.authedAccounts.set(accountId, accessToken);
489
+ }).finally(() => {
490
+ this.accountAuthPromises.delete(accountId);
491
+ });
492
+ this.accountAuthPromises.set(accountId, inFlight);
493
+ }
494
+ await inFlight;
495
+ }
496
+ wireConnection(conn) {
497
+ conn.on("data", (chunk) => {
498
+ for (const frame of this.parser.push(chunk)) this.dispatch(frame);
499
+ });
500
+ conn.on("error", (err) => {
501
+ log$1(`Socket error (${this.host}): ${err.message}`);
502
+ });
503
+ conn.on("close", () => {
504
+ if (!this.closing) this.handleDrop();
505
+ });
506
+ }
507
+ async appAuth() {
508
+ await this.request(PayloadType.OA_APPLICATION_AUTH_REQ, {
509
+ clientId: this.clientId,
510
+ clientSecret: this.clientSecret
511
+ });
512
+ }
513
+ startHeartbeat() {
514
+ this.stopHeartbeat();
515
+ this.heartbeatTimer = setInterval(() => {
516
+ if (!this.conn) return;
517
+ try {
518
+ const frame = encodeRequest(this.root, PayloadType.HEARTBEAT_EVENT, {}, randomUUID());
519
+ this.conn.write(frame);
520
+ } catch (err) {
521
+ log$1(`Heartbeat send failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
522
+ }
523
+ }, HEARTBEAT_INTERVAL_MS);
524
+ this.heartbeatTimer.unref();
525
+ }
526
+ stopHeartbeat() {
527
+ if (this.heartbeatTimer) {
528
+ clearInterval(this.heartbeatTimer);
529
+ this.heartbeatTimer = null;
530
+ }
531
+ }
532
+ handleDrop() {
533
+ log$1(`Socket dropped (${this.host}) — attempting reconnect`);
534
+ this.stopHeartbeat();
535
+ this.conn = null;
536
+ this.parser = new FrameParser();
537
+ this.authedAccounts.clear();
538
+ for (const [id, req] of this.pending) {
539
+ clearTimeout(req.timer);
540
+ req.reject(new CTraderError("CONNECTION_DROPPED", "Socket closed before a response arrived"));
541
+ this.pending.delete(id);
542
+ }
543
+ this.reconnect();
544
+ }
545
+ async reconnect() {
546
+ if (this.closing) return;
547
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
548
+ this.reconnectAttempts += 1;
549
+ await new Promise((r) => setTimeout(r, delay));
550
+ if (this.closing) return;
551
+ try {
552
+ this.conn = await this.connectFn(this.host, CTRADER_PORT);
553
+ this.wireConnection(this.conn);
554
+ await this.appAuth();
555
+ this.startHeartbeat();
556
+ this.reconnectAttempts = 0;
557
+ log$1(`Reconnected + re-app-authenticated (${this.host})`);
558
+ } catch (err) {
559
+ log$1(`Reconnect failed (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
560
+ this.reconnect();
561
+ }
562
+ }
563
+ dispatch(frame) {
564
+ let decoded;
565
+ try {
566
+ decoded = decodeEnvelope(this.root, frame);
567
+ } catch (err) {
568
+ log$1(`Failed to decode a frame (${this.host}): ${err instanceof Error ? err.message : String(err)}`);
569
+ return;
570
+ }
571
+ if (decoded.payloadType === PayloadType.HEARTBEAT_EVENT) return;
572
+ const id = decoded.clientMsgId;
573
+ if (!id) return;
574
+ const waiter = this.pending.get(id);
575
+ if (!waiter) return;
576
+ clearTimeout(waiter.timer);
577
+ this.pending.delete(id);
578
+ const err = this.extractError(decoded.payloadType, decoded.message);
579
+ if (err) {
580
+ waiter.reject(err);
581
+ return;
582
+ }
583
+ waiter.resolve({
584
+ payloadType: decoded.payloadType,
585
+ message: decoded.message
586
+ });
587
+ }
588
+ /**
589
+ * Map a cTrader error response to a `CTraderError`. Both the common-layer
590
+ * `ProtoErrorRes` (50) and the OA-layer `ProtoOAErrorRes` (2142) carry an
591
+ * `errorCode`; a broker-rejected order comes back as a dedicated
592
+ * `ProtoOAOrderErrorEvent` (2132) echoing the request `clientMsgId`, and
593
+ * some order-op rejections ride an execution event with `errorCode` set.
594
+ * Every one of these is a money-path reject — it MUST become a
595
+ * `CTraderError`, never resolve as success.
596
+ */
597
+ extractError(payloadType, message) {
598
+ if (payloadType === PayloadType.ERROR_RES || payloadType === PayloadType.OA_ERROR_RES) return new CTraderError(typeof message.errorCode === "string" ? message.errorCode : "UNKNOWN_ERROR", typeof message.description === "string" ? message.description : void 0);
599
+ if (payloadType === PayloadType.OA_ORDER_ERROR_EVENT) return new CTraderError(typeof message.errorCode === "string" ? message.errorCode : "ORDER_ERROR", typeof message.description === "string" ? message.description : void 0, {
600
+ orderId: str(message.orderId) || void 0,
601
+ positionId: str(message.positionId) || void 0
602
+ });
603
+ if (payloadType === PayloadType.OA_EXECUTION_EVENT && typeof message.errorCode === "string") return new CTraderError(message.errorCode);
604
+ return null;
605
+ }
606
+ /**
607
+ * Send a request and await the correlated response (matched by clientMsgId).
608
+ * Rejects with `CTraderError` on a cTrader error or timeout.
609
+ */
610
+ async request(payloadType, payload) {
611
+ if (!this.conn) throw new CTraderError("NOT_CONNECTED", `The cTrader socket to ${this.host} is not connected`);
612
+ const clientMsgId = randomUUID();
613
+ const frame = encodeRequest(this.root, payloadType, payload, clientMsgId);
614
+ return new Promise((resolve, reject) => {
615
+ const timer = setTimeout(() => {
616
+ this.pending.delete(clientMsgId);
617
+ reject(new CTraderError("REQUEST_TIMEOUT", `No response for payloadType ${String(payloadType)} within ${String(REQUEST_TIMEOUT_MS)}ms`));
618
+ }, REQUEST_TIMEOUT_MS);
619
+ timer.unref();
620
+ this.pending.set(clientMsgId, {
621
+ resolve,
622
+ reject,
623
+ timer
624
+ });
625
+ this.conn?.write(frame);
626
+ });
627
+ }
628
+ /** Clean shutdown: stop heartbeat, fail waiters, destroy the socket. */
629
+ close() {
630
+ this.closing = true;
631
+ this.stopHeartbeat();
632
+ for (const [id, req] of this.pending) {
633
+ clearTimeout(req.timer);
634
+ req.reject(new CTraderError("CLIENT_CLOSED", "Client is shutting down"));
635
+ this.pending.delete(id);
636
+ }
637
+ this.conn?.destroy();
638
+ this.conn = null;
639
+ this.authedAccounts.clear();
640
+ }
641
+ };
642
+ /**
643
+ * The account router. Owns the account registry and one `HostSocket` per
644
+ * DISTINCT host, created lazily on first use. Tools resolve an account through
645
+ * this pool: `request(accountId, payloadType, payload)` finds the account's
646
+ * host socket, ensures the account is account-authed there, and routes the
647
+ * request with the account's `ctidTraderAccountId` injected.
648
+ */
649
+ var CTraderPool = class {
650
+ registry;
651
+ connectFn;
652
+ root;
653
+ /** host → the single socket serving that host. Lazily populated. */
654
+ sockets = /* @__PURE__ */ new Map();
655
+ constructor(registry, connectFn = tlsConnect, root = loadRoot()) {
656
+ this.registry = registry;
657
+ this.connectFn = connectFn;
658
+ this.root = root;
659
+ }
660
+ /** The account registry (read-only view for tools that list/resolve). */
661
+ get accounts() {
662
+ return this.registry;
663
+ }
664
+ /** How many accounts are connected. Drives the required-vs-optional selector. */
665
+ get size() {
666
+ return this.registry.size;
667
+ }
668
+ /** The sole account's id when exactly one is connected, else null. */
669
+ get soleAccountId() {
670
+ if (this.registry.size !== 1) return null;
671
+ const [only] = this.registry.keys();
672
+ return only;
673
+ }
674
+ /** Look up an account's config by ctidTraderAccountId (string), or null. */
675
+ resolve(accountId) {
676
+ return this.registry.get(accountId) ?? null;
677
+ }
678
+ /** Lazily create + return the socket for a host (does not auth accounts). */
679
+ socketFor(config) {
680
+ let socket = this.sockets.get(config.host);
681
+ if (!socket) {
682
+ socket = new HostSocket(config.host, config.clientId, config.clientSecret, this.connectFn, this.root);
683
+ this.sockets.set(config.host, socket);
684
+ }
685
+ return socket;
686
+ }
687
+ /**
688
+ * Route a request to a specific account. Resolves the account → its host
689
+ * socket, ensures the socket is connected + app-authed and the account is
690
+ * account-authed, then sends the request. Every account-scoped request must
691
+ * go through here so it lands on the RIGHT host socket. The account's
692
+ * `ctidTraderAccountId` is NOT auto-injected into the payload — callers pass
693
+ * the full payload — but the request is guaranteed to run on the socket that
694
+ * serves this account's host.
695
+ *
696
+ * Throws `CTraderError("UNKNOWN_ACCOUNT")` if the id isn't in the registry
697
+ * (fail closed — never fall back to another account).
698
+ */
699
+ async request(accountId, payloadType, payload) {
700
+ const config = this.resolve(accountId);
701
+ if (!config) throw new CTraderError("UNKNOWN_ACCOUNT", `Account ${accountId} is not connected`);
702
+ const socket = this.socketFor(config);
703
+ await socket.authenticateAccount(config.accountId, config.accessToken);
704
+ return socket.request(payloadType, payload);
705
+ }
706
+ /** Close every host socket. */
707
+ close() {
708
+ for (const socket of this.sockets.values()) socket.close();
709
+ this.sockets.clear();
710
+ }
711
+ };
712
+ //#endregion
713
+ //#region src/tools.ts
714
+ /**
715
+ * MCP tool registration for the cTrader MCP server.
716
+ *
717
+ * Read tools: get_accounts, get_account_details, get_positions, get_orders,
718
+ * get_symbols, get_market_data
719
+ * Write tools: place_order, modify_order, close_position, cancel_order
720
+ *
721
+ * ── Account routing (money-safety) ──
722
+ * Every tool that acts on an account takes an OPTIONAL `accountId`
723
+ * (= ctidTraderAccountId):
724
+ * - exactly ONE account connected → `accountId` optional, defaults to it;
725
+ * - MULTIPLE accounts connected → `accountId` REQUIRED. If it's omitted or
726
+ * not in the registry, the tool returns an `isError` result that LISTS the
727
+ * available accounts so the agent can choose. It NEVER defaults to an
728
+ * arbitrary account when several exist — trading on the wrong (e.g. live vs
729
+ * demo) account is the exact failure this guards against.
730
+ * The resolved account is routed through `CTraderPool` to its host socket with
731
+ * its own `ctidTraderAccountId`.
732
+ *
733
+ * Every tool has a strict zod input schema, maps cTrader errors to MCP tool
734
+ * errors, and returns structured JSON. Volume is accepted as lots
735
+ * (human-friendly) and converted to protocol centi-units via the symbol's
736
+ * lotSize.
737
+ */
738
+ const ORDER_TYPE = {
739
+ MARKET: 1,
740
+ LIMIT: 2,
741
+ STOP: 3
742
+ };
743
+ const TRADE_SIDE = {
744
+ BUY: 1,
745
+ SELL: 2
746
+ };
747
+ /**
748
+ * ProtoOAExecutionType (OpenApiModelMessages.proto). The number the broker
749
+ * returns on a ProtoOAExecutionEvent tells us the real outcome — a bare
750
+ * "placed: true" hides a REJECTED/CANCELLED fill. Values transcribed verbatim.
751
+ */
752
+ const EXECUTION_TYPE_NAME = {
753
+ 2: "ORDER_ACCEPTED",
754
+ 3: "ORDER_FILLED",
755
+ 4: "ORDER_REPLACED",
756
+ 5: "ORDER_CANCELLED",
757
+ 6: "ORDER_EXPIRED",
758
+ 7: "ORDER_REJECTED",
759
+ 8: "ORDER_CANCEL_REJECTED",
760
+ 9: "SWAP",
761
+ 10: "DEPOSIT_WITHDRAW",
762
+ 11: "ORDER_PARTIAL_FILL",
763
+ 12: "BONUS_DEPOSIT_WITHDRAW"
764
+ };
765
+ /** Execution types that mean the write did NOT succeed as intended. */
766
+ const FAILED_EXECUTION_TYPES = new Set([
767
+ 5,
768
+ 6,
769
+ 7,
770
+ 8
771
+ ]);
772
+ const TRENDBAR_PERIOD = {
773
+ M1: 1,
774
+ M2: 2,
775
+ M3: 3,
776
+ M4: 4,
777
+ M5: 5,
778
+ M10: 6,
779
+ M15: 7,
780
+ M30: 8,
781
+ H1: 9,
782
+ H4: 10,
783
+ H12: 11,
784
+ D1: 12,
785
+ W1: 13,
786
+ MN1: 14
787
+ };
788
+ function ok(data) {
789
+ return { content: [{
790
+ type: "text",
791
+ text: JSON.stringify(data, null, 2)
792
+ }] };
793
+ }
794
+ function fail(err) {
795
+ const message = err instanceof CTraderError ? err.message : err instanceof Error ? err.message : String(err);
796
+ const errorCode = err instanceof CTraderError ? err.errorCode : "TOOL_ERROR";
797
+ return {
798
+ content: [{
799
+ type: "text",
800
+ text: JSON.stringify({
801
+ error: errorCode,
802
+ message
803
+ }, null, 2)
804
+ }],
805
+ isError: true
806
+ };
807
+ }
808
+ function num(v) {
809
+ return Number(v ?? 0);
810
+ }
811
+ /** Serialize an account for a listing (both the registry and error payloads). */
812
+ function describeAccount(config) {
813
+ return {
814
+ accountId: String(config.accountId),
815
+ isLive: config.isLive,
816
+ host: config.host,
817
+ broker: config.brokerName ?? null,
818
+ accountNumber: config.accountNumber ?? null
819
+ };
820
+ }
821
+ /**
822
+ * Resolve the account an account-scoped tool should act on.
823
+ *
824
+ * - MULTIPLE accounts + `accountId` omitted → error listing the accounts
825
+ * (REQUIRED-selector case: never default to an arbitrary account).
826
+ * - `accountId` given but not in the registry → error listing the accounts.
827
+ * - single account + `accountId` omitted → default to the sole account.
828
+ * - `accountId` given and valid → that account.
829
+ *
830
+ * Returns the resolved `CTraderConfig` on success, or a `ToolResult` (isError)
831
+ * describing the ambiguity/miss on failure. This IS the money-safety gate.
832
+ */
833
+ function resolveAccount(pool, accountId) {
834
+ const listing = () => [...pool.accounts.values()].map(describeAccount);
835
+ if (accountId == null) {
836
+ const sole = pool.soleAccountId;
837
+ if (sole == null) return {
838
+ content: [{
839
+ type: "text",
840
+ text: JSON.stringify({
841
+ error: "ACCOUNT_ID_REQUIRED",
842
+ message: "Multiple cTrader accounts are connected — pass `accountId` (ctidTraderAccountId) to choose one. This tool will NOT default to an account when several exist, to avoid trading on the wrong (e.g. live vs demo) account.",
843
+ accounts: listing()
844
+ }, null, 2)
845
+ }],
846
+ isError: true
847
+ };
848
+ const config = pool.resolve(sole);
849
+ if (config) return config;
850
+ } else {
851
+ const config = pool.resolve(accountId);
852
+ if (config) return config;
853
+ }
854
+ return {
855
+ content: [{
856
+ type: "text",
857
+ text: JSON.stringify({
858
+ error: "UNKNOWN_ACCOUNT",
859
+ message: `No connected cTrader account with ctidTraderAccountId "${accountId ?? ""}". Pick one of the accounts below (by its accountId).`,
860
+ accounts: listing()
861
+ }, null, 2)
862
+ }],
863
+ isError: true
864
+ };
865
+ }
866
+ /** Narrow a resolveAccount() return to its error branch. */
867
+ function isToolError(v) {
868
+ return "content" in v;
869
+ }
870
+ /** Fetch a symbol's full detail (for lotSize / volume rules / digits). */
871
+ async function getSymbolDetail(pool, accountId, numericAccountId, symbolId) {
872
+ const symbols = (await pool.request(accountId, PayloadType.OA_SYMBOL_BY_ID_REQ, {
873
+ ctidTraderAccountId: numericAccountId,
874
+ symbolId: [symbolId]
875
+ })).message.symbol ?? [];
876
+ if (symbols.length === 0) throw new CTraderError("SYMBOL_NOT_FOUND", `No symbol with id ${String(symbolId)} on this account`);
877
+ return symbols[0];
878
+ }
879
+ /**
880
+ * The shared `accountId` selector for account-scoped tools. Optional at the
881
+ * schema level (single-account convenience); `resolveAccount` enforces the
882
+ * required-when-multiple rule at runtime so the model gets a helpful listing
883
+ * instead of a bare validation error.
884
+ */
885
+ const accountIdField = z.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.");
886
+ function registerTools(server, pool) {
887
+ const register = server.registerTool.bind(server);
888
+ register("get_accounts", {
889
+ 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.",
890
+ inputSchema: {}
891
+ }, () => {
892
+ return ok({
893
+ count: pool.size,
894
+ defaultAccountId: pool.soleAccountId,
895
+ accounts: [...pool.accounts.values()].map(describeAccount)
896
+ });
897
+ });
898
+ register("get_account_details", {
899
+ description: "Get balance and account details for a connected cTrader account. Balance is returned as a real currency figure (converted from wire units via moneyDigits). Pass `accountId` when several accounts are connected.",
900
+ inputSchema: { accountId: accountIdField }
901
+ }, async (args) => {
902
+ const resolved = resolveAccount(pool, args.accountId);
903
+ if (isToolError(resolved)) return resolved;
904
+ try {
905
+ const trader = (await pool.request(String(resolved.accountId), PayloadType.OA_TRADER_REQ, { ctidTraderAccountId: resolved.accountId })).message.trader ?? {};
906
+ const moneyDigits = trader.moneyDigits != null ? num(trader.moneyDigits) : 2;
907
+ return ok({
908
+ ctidTraderAccountId: str(trader.ctidTraderAccountId) || String(resolved.accountId),
909
+ balance: moneyToDecimal(num(trader.balance), moneyDigits),
910
+ balanceRaw: str(trader.balance) || "0",
911
+ moneyDigits,
912
+ depositAssetId: str(trader.depositAssetId) || null,
913
+ maxLeverage: trader.maxLeverage ?? null,
914
+ brokerName: trader.brokerName ?? null,
915
+ accountType: trader.accountType ?? null
916
+ });
917
+ } catch (err) {
918
+ return fail(err);
919
+ }
920
+ });
921
+ register("get_positions", {
922
+ description: "List open positions on a connected cTrader account, with entry price, volume, side, SL/TP, and swap. Pass `accountId` when several accounts are connected.",
923
+ inputSchema: { accountId: accountIdField }
924
+ }, async (args) => {
925
+ const resolved = resolveAccount(pool, args.accountId);
926
+ if (isToolError(resolved)) return resolved;
927
+ try {
928
+ const positions = (await pool.request(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
929
+ ctidTraderAccountId: resolved.accountId,
930
+ returnProtectionOrders: true
931
+ })).message.position ?? [];
932
+ return ok({
933
+ accountId: String(resolved.accountId),
934
+ positions: positions.map((p) => {
935
+ const td = p.tradeData ?? {};
936
+ return {
937
+ positionId: str(p.positionId),
938
+ symbolId: str(td.symbolId),
939
+ side: num(td.tradeSide) === TRADE_SIDE.SELL ? "SELL" : "BUY",
940
+ volume: str(td.volume) || "0",
941
+ entryPrice: p.price ?? null,
942
+ stopLoss: p.stopLoss ?? null,
943
+ takeProfit: p.takeProfit ?? null,
944
+ swap: str(p.swap) || null
945
+ };
946
+ })
947
+ });
948
+ } catch (err) {
949
+ return fail(err);
950
+ }
951
+ });
952
+ register("get_orders", {
953
+ description: "List pending (not-yet-filled) orders on a connected cTrader account, with type, side, volume, and trigger prices. Pass `accountId` when several accounts are connected.",
954
+ inputSchema: { accountId: accountIdField }
955
+ }, async (args) => {
956
+ const resolved = resolveAccount(pool, args.accountId);
957
+ if (isToolError(resolved)) return resolved;
958
+ try {
959
+ const orders = (await pool.request(String(resolved.accountId), PayloadType.OA_RECONCILE_REQ, {
960
+ ctidTraderAccountId: resolved.accountId,
961
+ returnProtectionOrders: true
962
+ })).message.order ?? [];
963
+ const typeName = (v) => v === ORDER_TYPE.LIMIT ? "LIMIT" : v === ORDER_TYPE.STOP ? "STOP" : v === ORDER_TYPE.MARKET ? "MARKET" : String(v);
964
+ return ok({
965
+ accountId: String(resolved.accountId),
966
+ orders: orders.map((o) => {
967
+ const td = o.tradeData ?? {};
968
+ return {
969
+ orderId: str(o.orderId),
970
+ symbolId: str(td.symbolId),
971
+ orderType: typeName(num(o.orderType)),
972
+ side: num(td.tradeSide) === TRADE_SIDE.SELL ? "SELL" : "BUY",
973
+ volume: str(td.volume) || "0",
974
+ limitPrice: o.limitPrice ?? null,
975
+ stopPrice: o.stopPrice ?? null,
976
+ stopLoss: o.stopLoss ?? null,
977
+ takeProfit: o.takeProfit ?? null
978
+ };
979
+ })
980
+ });
981
+ } catch (err) {
982
+ return fail(err);
983
+ }
984
+ });
985
+ register("get_symbols", {
986
+ 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.",
987
+ inputSchema: {
988
+ accountId: accountIdField,
989
+ nameFilter: z.string().optional().describe("Case-insensitive substring to filter symbol names, e.g. \"EUR\"")
990
+ }
991
+ }, async (args) => {
992
+ const resolved = resolveAccount(pool, args.accountId);
993
+ if (isToolError(resolved)) return resolved;
994
+ try {
995
+ let symbols = (await pool.request(String(resolved.accountId), PayloadType.OA_SYMBOLS_LIST_REQ, {
996
+ ctidTraderAccountId: resolved.accountId,
997
+ includeArchivedSymbols: false
998
+ })).message.symbol ?? [];
999
+ if (args.nameFilter) {
1000
+ const needle = args.nameFilter.toLowerCase();
1001
+ symbols = symbols.filter((s) => str(s.symbolName).toLowerCase().includes(needle));
1002
+ }
1003
+ return ok({
1004
+ accountId: String(resolved.accountId),
1005
+ count: symbols.length,
1006
+ symbols: symbols.map((s) => ({
1007
+ symbolId: str(s.symbolId),
1008
+ name: s.symbolName ?? null,
1009
+ enabled: Boolean(s.enabled),
1010
+ description: s.description ?? null
1011
+ }))
1012
+ });
1013
+ } catch (err) {
1014
+ return fail(err);
1015
+ }
1016
+ });
1017
+ register("get_market_data", {
1018
+ 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.",
1019
+ inputSchema: {
1020
+ accountId: accountIdField,
1021
+ symbolId: z.number().int().positive().describe("The symbol id from get_symbols"),
1022
+ period: z.enum(Object.keys(TRENDBAR_PERIOD)).default("H1").describe("Candle period: M1, M5, M15, M30, H1, H4, D1, W1, MN1, etc."),
1023
+ count: z.number().int().min(1).max(1e3).default(50).describe("Number of most-recent bars to return (1-1000)")
1024
+ }
1025
+ }, async (args) => {
1026
+ const resolved = resolveAccount(pool, args.accountId);
1027
+ if (isToolError(resolved)) return resolved;
1028
+ try {
1029
+ const period = TRENDBAR_PERIOD[args.period];
1030
+ const bars = (await pool.request(String(resolved.accountId), PayloadType.OA_GET_TRENDBARS_REQ, {
1031
+ ctidTraderAccountId: resolved.accountId,
1032
+ symbolId: args.symbolId,
1033
+ period,
1034
+ count: args.count
1035
+ })).message.trendbar ?? [];
1036
+ return ok({
1037
+ accountId: String(resolved.accountId),
1038
+ symbolId: String(args.symbolId),
1039
+ period: args.period,
1040
+ bars: bars.map((b) => decodeTrendbar(b))
1041
+ });
1042
+ } catch (err) {
1043
+ return fail(err);
1044
+ }
1045
+ });
1046
+ register("place_order", {
1047
+ description: "Place a MARKET or 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). For LIMIT you must pass limitPrice. SL/TP are absolute prices. Pass `accountId` when several accounts are connected. WARNING: on a live account this moves real money.",
1048
+ inputSchema: {
1049
+ accountId: accountIdField,
1050
+ symbolId: z.number().int().positive().describe("Symbol id from get_symbols"),
1051
+ side: z.enum(["BUY", "SELL"]).describe("Trade side"),
1052
+ orderType: z.enum(["MARKET", "LIMIT"]).default("MARKET").describe("MARKET fills now; LIMIT rests at limitPrice"),
1053
+ volumeLots: z.number().positive().describe("Order size in lots (e.g. 0.1). Converted to protocol volume via the symbol lotSize."),
1054
+ limitPrice: z.number().positive().optional().describe("Required for LIMIT orders — the price to rest the order at"),
1055
+ stopLoss: z.number().positive().optional().describe("Absolute stop-loss price"),
1056
+ takeProfit: z.number().positive().optional().describe("Absolute take-profit price"),
1057
+ label: z.string().optional().describe("Optional client label for the order")
1058
+ }
1059
+ }, async (args) => {
1060
+ const resolved = resolveAccount(pool, args.accountId);
1061
+ if (isToolError(resolved)) return resolved;
1062
+ const accountId = String(resolved.accountId);
1063
+ try {
1064
+ if (args.orderType === "LIMIT" && args.limitPrice == null) return fail(new CTraderError("LIMIT_PRICE_REQUIRED", "LIMIT orders require limitPrice"));
1065
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, args.symbolId);
1066
+ const lotSize = num(symbol.lotSize);
1067
+ if (lotSize <= 0) return fail(new CTraderError("SYMBOL_NO_LOTSIZE", `Symbol ${String(args.symbolId)} has no lotSize; cannot size the order`));
1068
+ const volume = lotsToVolume(args.volumeLots, lotSize);
1069
+ const volErr = validateVolume(volume, num(symbol.minVolume), num(symbol.maxVolume), num(symbol.stepVolume));
1070
+ if (volErr) return fail(new CTraderError("INVALID_VOLUME", volErr));
1071
+ const payload = {
1072
+ ctidTraderAccountId: resolved.accountId,
1073
+ symbolId: args.symbolId,
1074
+ orderType: ORDER_TYPE[args.orderType],
1075
+ tradeSide: TRADE_SIDE[args.side],
1076
+ volume
1077
+ };
1078
+ if (args.limitPrice != null) payload.limitPrice = args.limitPrice;
1079
+ if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1080
+ if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1081
+ if (args.label != null) payload.label = args.label;
1082
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_NEW_ORDER_REQ, payload));
1083
+ return ok({
1084
+ accountId,
1085
+ executionType,
1086
+ volume,
1087
+ volumeLots: volumeToLots(volume, lotSize),
1088
+ execution: summary
1089
+ });
1090
+ } catch (err) {
1091
+ return fail(err);
1092
+ }
1093
+ });
1094
+ register("modify_order", {
1095
+ 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.",
1096
+ inputSchema: {
1097
+ accountId: accountIdField,
1098
+ orderId: z.number().int().positive().optional().describe("Pending order id (from get_orders) to amend"),
1099
+ positionId: z.number().int().positive().optional().describe("Open position id (from get_positions) to set SL/TP on"),
1100
+ limitPrice: z.number().positive().optional().describe("New limit price (pending order only)"),
1101
+ stopPrice: z.number().positive().optional().describe("New stop price (pending order only)"),
1102
+ stopLoss: z.number().positive().optional().describe("New absolute stop-loss price"),
1103
+ takeProfit: z.number().positive().optional().describe("New absolute take-profit price")
1104
+ }
1105
+ }, async (args) => {
1106
+ const resolved = resolveAccount(pool, args.accountId);
1107
+ if (isToolError(resolved)) return resolved;
1108
+ const accountId = String(resolved.accountId);
1109
+ try {
1110
+ if (args.orderId == null === (args.positionId == null)) return fail(new CTraderError("INVALID_TARGET", "Provide exactly one of orderId or positionId"));
1111
+ if (args.positionId != null) {
1112
+ const payload = {
1113
+ ctidTraderAccountId: resolved.accountId,
1114
+ positionId: args.positionId
1115
+ };
1116
+ if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1117
+ if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1118
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_POSITION_SLTP_REQ, payload));
1119
+ return ok({
1120
+ accountId,
1121
+ executionType,
1122
+ positionId: String(args.positionId),
1123
+ execution: summary
1124
+ });
1125
+ }
1126
+ const payload = {
1127
+ ctidTraderAccountId: resolved.accountId,
1128
+ orderId: args.orderId
1129
+ };
1130
+ if (args.limitPrice != null) payload.limitPrice = args.limitPrice;
1131
+ if (args.stopPrice != null) payload.stopPrice = args.stopPrice;
1132
+ if (args.stopLoss != null) payload.stopLoss = args.stopLoss;
1133
+ if (args.takeProfit != null) payload.takeProfit = args.takeProfit;
1134
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_AMEND_ORDER_REQ, payload));
1135
+ return ok({
1136
+ accountId,
1137
+ executionType,
1138
+ orderId: String(args.orderId),
1139
+ execution: summary
1140
+ });
1141
+ } catch (err) {
1142
+ return fail(err);
1143
+ }
1144
+ });
1145
+ register("close_position", {
1146
+ 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.",
1147
+ inputSchema: {
1148
+ accountId: accountIdField,
1149
+ positionId: z.number().int().positive().describe("Position id from get_positions"),
1150
+ volumeLots: z.number().positive().optional().describe("Lots to close; omit to close the whole position")
1151
+ }
1152
+ }, async (args) => {
1153
+ const resolved = resolveAccount(pool, args.accountId);
1154
+ if (isToolError(resolved)) return resolved;
1155
+ const accountId = String(resolved.accountId);
1156
+ try {
1157
+ const pos = ((await pool.request(accountId, PayloadType.OA_RECONCILE_REQ, {
1158
+ ctidTraderAccountId: resolved.accountId,
1159
+ returnProtectionOrders: true
1160
+ })).message.position ?? []).find((p) => str(p.positionId) === String(args.positionId));
1161
+ if (!pos) return fail(new CTraderError("POSITION_NOT_FOUND", `No open position ${String(args.positionId)}`));
1162
+ const td = pos.tradeData ?? {};
1163
+ const fullVolume = num(td.volume);
1164
+ let volume = fullVolume;
1165
+ if (args.volumeLots != null) {
1166
+ const symbol = await getSymbolDetail(pool, accountId, resolved.accountId, num(td.symbolId));
1167
+ volume = lotsToVolume(args.volumeLots, num(symbol.lotSize));
1168
+ if (volume > fullVolume) return fail(new CTraderError("VOLUME_EXCEEDS_POSITION", `Requested ${String(volume)} exceeds position volume ${String(fullVolume)}`));
1169
+ }
1170
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CLOSE_POSITION_REQ, {
1171
+ ctidTraderAccountId: resolved.accountId,
1172
+ positionId: args.positionId,
1173
+ volume
1174
+ }));
1175
+ return ok({
1176
+ accountId,
1177
+ executionType,
1178
+ positionId: String(args.positionId),
1179
+ volume,
1180
+ execution: summary
1181
+ });
1182
+ } catch (err) {
1183
+ return fail(err);
1184
+ }
1185
+ });
1186
+ register("cancel_order", {
1187
+ 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.",
1188
+ inputSchema: {
1189
+ accountId: accountIdField,
1190
+ orderId: z.number().int().positive().describe("Pending order id from get_orders")
1191
+ }
1192
+ }, async (args) => {
1193
+ const resolved = resolveAccount(pool, args.accountId);
1194
+ if (isToolError(resolved)) return resolved;
1195
+ const accountId = String(resolved.accountId);
1196
+ try {
1197
+ const { executionType, summary } = assertExecution(await pool.request(accountId, PayloadType.OA_CANCEL_ORDER_REQ, {
1198
+ ctidTraderAccountId: resolved.accountId,
1199
+ orderId: args.orderId
1200
+ }), new Set([5]));
1201
+ return ok({
1202
+ accountId,
1203
+ executionType,
1204
+ orderId: String(args.orderId),
1205
+ execution: summary
1206
+ });
1207
+ } catch (err) {
1208
+ return fail(err);
1209
+ }
1210
+ });
1211
+ }
1212
+ /** Compact the ProtoOAExecutionEvent response into a stable summary. */
1213
+ function summariseExecution(message) {
1214
+ const order = message.order;
1215
+ const position = message.position;
1216
+ const executionTypeCode = message.executionType != null ? num(message.executionType) : null;
1217
+ return {
1218
+ executionType: executionTypeCode != null ? EXECUTION_TYPE_NAME[executionTypeCode] ?? String(executionTypeCode) : null,
1219
+ executionTypeCode,
1220
+ orderId: str(order?.orderId) || null,
1221
+ positionId: str(position?.positionId) || null,
1222
+ executionPrice: order?.executionPrice ?? position?.price ?? null
1223
+ };
1224
+ }
1225
+ /**
1226
+ * Turn a write-tool reply into a definite outcome. A cTrader order op is
1227
+ * expected to come back as a ProtoOAExecutionEvent (2126); anything else means
1228
+ * we can't confirm the write landed, and a rejection/cancel executionType means
1229
+ * it explicitly did NOT. In both cases we throw a CTraderError so the tool
1230
+ * result is an unambiguous error rather than a bare success — this is a money
1231
+ * path and must fail closed.
1232
+ */
1233
+ function assertExecution(res, extraOkTypes = /* @__PURE__ */ new Set()) {
1234
+ 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`);
1235
+ const summary = summariseExecution(res.message);
1236
+ const code = res.message.executionType != null ? num(res.message.executionType) : null;
1237
+ const executionType = typeof summary.executionType === "string" ? summary.executionType : "UNKNOWN";
1238
+ if (code != null && FAILED_EXECUTION_TYPES.has(code) && !extraOkTypes.has(code)) throw new CTraderError(executionType, `cTrader returned a non-fill execution (${executionType})`, {
1239
+ orderId: str(summary.orderId) || void 0,
1240
+ positionId: str(summary.positionId) || void 0
1241
+ });
1242
+ return {
1243
+ executionType,
1244
+ summary
1245
+ };
1246
+ }
1247
+ //#endregion
1248
+ //#region src/server.ts
1249
+ /**
1250
+ * cTrader MCP Server
1251
+ *
1252
+ * Standalone stdio MCP server wrapping the cTrader Open API (protobuf over a
1253
+ * persistent TLS socket, port 5035). Unlike the OpenClaw plugin packages this
1254
+ * is runtime-agnostic — it is spawned via `npx -y @alfe.ai/ctrader-mcp` from an
1255
+ * integration manifest and speaks MCP over stdio, so it runs under any runtime
1256
+ * that can spawn `npx`.
1257
+ *
1258
+ * Architecture:
1259
+ * Agent runtime ←(stdio/MCP)→ this server ←(protobuf/TLS 5035)→ cTrader Open API
1260
+ *
1261
+ * ── Multi-account ──
1262
+ * Credentials are self-fetched at startup (the atlassian/google pattern):
1263
+ * `resolveConfig()` yields the agent's `{ apiKey, apiUrl }`, and
1264
+ * `AgentApiClient.getCTraderAccounts()` returns the full set of authorized
1265
+ * trading accounts on one OAuth grant — one shared `accessToken` +
1266
+ * SST-global `clientId`/`clientSecret`, with a per-account `ctidTraderAccountId`
1267
+ * and `host` (live vs demo). We build an account registry and drive a
1268
+ * `CTraderPool` that opens one authenticated socket per distinct host and
1269
+ * routes each tool call to the account it names (see client.ts). No account is
1270
+ * ever chosen implicitly when several are connected — the tools require an
1271
+ * explicit `accountId` in that case.
1272
+ */
1273
+ function log(msg) {
1274
+ process.stderr.write(`[ctrader-mcp] ${msg}\n`);
1275
+ }
1276
+ async function main() {
1277
+ const { apiKey, apiUrl } = resolveConfig();
1278
+ const apiClient = new AgentApiClient({
1279
+ apiKey,
1280
+ apiUrl
1281
+ });
1282
+ let registry;
1283
+ try {
1284
+ registry = buildRegistry(await apiClient.getCTraderAccounts());
1285
+ } catch (err) {
1286
+ if (err instanceof ConfigError) log(`No usable cTrader connection: ${err.message}`);
1287
+ else log(`Failed to resolve cTrader accounts: ${err instanceof Error ? err.message : String(err)}`);
1288
+ process.exit(1);
1289
+ }
1290
+ const pool = new CTraderPool(registry);
1291
+ const server = new McpServer({
1292
+ name: "ctrader-mcp-server",
1293
+ version: "0.0.1"
1294
+ });
1295
+ registerTools(server, pool);
1296
+ const shutdown = () => {
1297
+ pool.close();
1298
+ process.exit(0);
1299
+ };
1300
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, shutdown);
1301
+ const transport = new StdioServerTransport();
1302
+ await server.connect(transport);
1303
+ const hosts = [...new Set([...registry.values()].map((c) => c.host))].join(", ");
1304
+ log(`cTrader MCP server running (${String(registry.size)} account(s) across host(s): ${hosts}) with full-trading tools`);
1305
+ }
1306
+ main().catch((err) => {
1307
+ log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
1308
+ process.exit(1);
1309
+ });
1310
+ //#endregion
1311
+ export {};