@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.d.ts +1 -0
- package/dist/server.js +1311 -0
- package/package.json +48 -0
- package/proto/ctrader.proto +447 -0
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 {};
|