mcp-mt4-manapi
by ruslan-yb
README.md
# mcp-mt4-manapi
MCP server for the **MT4 Manager API**. Exposes **30 tools** covering accounts, the open
order book, dealer-side trading, balance operations, group and symbol configuration, tick
injection, the server journal and the floating-leverage plugin's own output.
Talks to `mtmanapi64.dll` directly through `ctypes` — there is no C++ shim and no compiler
in the build. Two files:
| File | What it is |
|---|---|
| [`mt4_api.py`](mt4_api.py) | the ctypes layer: structs, vtable slots, one connection class |
| [`server.py`](server.py) | the FastMCP tools |
> **Resuming, or new to this?** Read [`HANDOFF.md`](HANDOFF.md) first — state of play,
> open threads, and the traps that have already produced a wrong conclusion.
> **Test stands only.** The MT4 Manager API has no undo. This server is built for a test
> stand and its write fences assume a shared one — see *Namespaces* below.
## Install
```powershell
cd <where you cloned it>\mcp-mt4-manapi
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
copy .env.example .env # then fill it in
```
**You also need `mtmanapi64.dll`**, the MetaQuotes client library that speaks the Manager
protocol. It is not in this repository — take it from the MT4 Manager API package — and it
is *not* a server plugin: it runs on your machine, inside this process. Drop it next to
`server.py` (it is git-ignored) or point `MT4_DLL_PATH` at it.
Python must be **64-bit** to load `mtmanapi64.dll`. The DLL's bitness must match *Python*,
not the server: a 64-bit client talks to a 32-bit MT4 server over TCP without trouble.
### Setting it up for yourself
Everything that is yours goes in `.env` next to `server.py`, or in the MCP client's `env`
block (which wins). `.env` is re-read on every lookup, so an edit takes effect without a
restart — `use_environment()` reconnects with it.
**1. Your environments.** One MT4 server plus the manager login you use on it, under a name
you choose. Define as many as you work with:
```ini
MT4_ENV=lab # the one to start on
MT4_LAB_SERVER=10.0.0.5:443 # the MANAGER port
MT4_LAB_LOGIN=1234
MT4_STAGE_SERVER=10.0.0.9:443
MT4_STAGE_LOGIN=77
```
Switch in a session with `use_environment("stage")`; `list_environments` shows what is
configured and which one is active. With a single server you can skip names and use plain
`MT4_SERVER` / `MT4_LOGIN` — that one is called `default`.
**2. Your password — never in a file, never in the chat.** The first time an environment
needs one, a small window opens on your screen. The password is checked by logging in and
then stored in your **Windows Credential Manager** (entry `mcp-mt4-manapi:<server>:<login>`),
encrypted by Windows for your account. It never passes through a tool call, so it never
reaches the AI assistant, the transcript or a log. To change it: `set_password("<env>")`
opens the window again; a wrong password is not stored. Without a desktop session, run
`python server.py --set-password <env>` in a terminal. A legacy `MT4_PASSWORD` in config
still works and is reported as `passwordSource: "config-plaintext"` — move it with
`set_password` and delete it from the file.
**3. Your namespace.**
| You set | For example | Without it |
|---|---|---|
| `MT4_GROUP_WRITE_PREFIXES` | your group prefix, `xx-` | group writes are **refused** |
| `MT4_SYMBOL_WRITE_SUFFIXES` | your symbol suffix, `.xx` | symbol writes are **refused** |
| `MT4_FL_PLUGIN` | your plugin instance, `fl_xx` | FL reads mix every instance on the server |
Each can also be set per environment — `MT4_LAB_GROUP_WRITE_PREFIXES`, `MT4_LAB_FL_PLUGIN` —
and falls back to the global value. There are deliberately no defaults for the write fences:
a default would be somebody's namespace. Your plugin instance's name is what the journal
shows in its source column — see it with `get_journal(minutes=5, source_col="fl_")`.
### Working on a shared server
The server is shared with other engineers, and a trade, a deposit or a symbol change lands
in the middle of their tests. So the rule the tools carry for the assistant is: **if you have
not said which account to use and which symbols, it asks — it never picks one itself**, not
even a test-looking login or the one from the previous task. Say which environment, which
login and which symbols when you ask for a trading step.
## Configuration
All settings are environment variables; nothing is hardcoded and no credential is committed.
See [`.env.example`](.env.example) for the full list.
| Variable | Purpose |
|---|---|
| `MT4_ENV` | the environment to start on |
| `MT4_<NAME>_SERVER` / `MT4_<NAME>_LOGIN` | one environment: `host:port` of the **manager** port, and the manager login |
| `MT4_SERVER` / `MT4_LOGIN` | the unnamed `default` environment, if you only have one |
| `MT4_<NAME>_<SETTING>` | any setting below, for one environment only |
| `MT4_DLL_PATH` | path to `mtmanapi64.dll` (default: next to `server.py`) |
| `MT4_GROUP_WRITE_PREFIXES` | group prefixes this server may write — **no default, writes refused until set** |
| `MT4_SYMBOL_WRITE_SUFFIXES` | symbol suffixes this server may write — **no default** |
| `MT4_FL_PLUGIN` | default `plugin=` for `get_fl_activity` / `probe_margin`, e.g. `fl_xx` |
| `MT4_PROTECTED_LOGINS` | logins refused for trade and money calls |
## Namespaces — the stand is shared
A typical shared test stand carries several engineers' namespaces, keyed by initials:
| Owner | Groups | Symbols | FL plugin instance |
|---|---|---|---|
| engineer A | `aa-*` | `*.aa` | `fl_aa` |
| engineer B | `bb-*` | `*.bb` | `fl_bb` |
Unsuffixed symbols (`EURUSD`) are **shared by everyone**.
A group or symbol write takes effect server-wide and immediately, and MT4 offers no owner
field — the name is the only marker. So `create_group`, `create_symbol`,
`set_group_security`, `update_symbol_margins` and `send_tick` refuse anything outside the
configured prefix/suffix. Trading and money calls are **not** fenced by group (a test often
has to trade an account someone else provisioned); they are fenced by
`MT4_PROTECTED_LOGINS`.
`restart_server` is fenced differently again: it needs `confirm=True`, because it restarts
the whole MT4 server for every engineer on the stand.
## Tools
Full arguments and docstrings: [`docs/TOOLS.md`](docs/TOOLS.md) (generated).
**Connection** — `health_check`, `reconnect`, `list_environments`, `use_environment`, `set_password`
**Accounts** — `get_accounts`, `get_margin_level`, `create_account`, `set_account_leverage`, `balance_operation`
**Market** — `get_quote`
**Trading** — `get_positions`, `place_order`, `close_position`, `close_all_positions`, `delete_pending_order`, `get_trade_history`
**Floating leverage** — `probe_margin`, `get_fl_activity`
**Groups** — `get_groups`, `get_group_details`, `create_group`, `set_group_security`
**Symbols** — `get_symbols`, `get_symbol_details`, `create_symbol`, `update_symbol_margins`, `send_tick`
**Server** — `get_journal`, `restart_server`
### The three that save the most round trips
`probe_margin` opens one position, reads what floating leverage did to it, cross-checks the
plugin's own claimed multiplier against the margin actually charged, and closes it again.
Measured on a test stand: **6 calls / 417 tokens → 1 call / 263 tokens**, and the old path could not
attribute a reading to a rule at all.
`get_fl_activity` parses the plugin's journal output into small records — rule delivery,
which rule priced which symbol, conversion failures, rate mismatches, corrupt data files.
Raw lines are 200–500 chars; parsed records are ~60.
`close_all_positions` replaces the `get_positions` + N × `close_position` loop that
flattening between cases otherwise needs.
Every read takes a filter, a `limit` and a narrow shape, because the underlying API has no
server-side filtering at all — each call pulls the whole table and filters locally.
Measured on a test stand:
| Call | Tokens |
|---|---|
| `get_accounts(compact=False)` — all 172 | ~26 500 |
| `get_accounts(compact=True)` — default | ~5 200 |
| `get_accounts(group="xx-*")` | ~1 200 |
| `get_symbols(names_only=False)` — all 409 | ~10 800 |
| `get_symbols()` — names only, default | ~1 200 |
| `get_symbols(writable_only=True)` | ~290 |
| `get_journal(minutes=60, contains="...")` — text output, default | ~650 |
| `probe_margin(...)` — a whole FL measurement | ~260 |
| `get_fl_activity(kinds="calc_ruled")` | ~210 |
| `close_all_positions(login)` | ~20 |
The whole tool-definition set costs ~12 600 tokens once per session (`measure_tools.py`,
recorded in `tool_budget.json`), and far less when the client defers schemas to names.
The unfiltered account dump alone is twice that — filter.
## Dev loop
```powershell
# Gate 1 - imports and registers
.\.venv\Scripts\python.exe -c "import asyncio, server; print('OK', len(asyncio.run(server.mcp.list_tools())), 'tools')"
# Expected: OK 30 tools
# Gate 2 - tool-definition weight against tool_budget.json (exit 1 when over budget)
.\.venv\Scripts\python.exe measure_tools.py
# After changing a docstring, regenerate the tool reference too:
.\.venv\Scripts\python.exe measure_tools.py --docs
# Accept a deliberate increase (say what it buys in the commit message):
.\.venv\Scripts\python.exe measure_tools.py --update
# Gate 3 - actually serves (gate 1 passes on a server that never calls mcp.run())
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' | .\.venv\Scripts\python.exe server.py
```
A running MCP client keeps executing the code it loaded at launch — after a change,
reconnect (`/mcp` in Claude Code, reload in the VS Code MCP panel, restart Claude Desktop).
## Things that cost hours if you do not know them
These are the traps this server already handles, recorded so the next change does not
reintroduce them.
- **Struct packing is per struct, not per file.** Only `TradeRecord` and `TradeTransInfo`
are `#pragma pack(1)`; `ConSymbol`, `ConGroup`, `UserRecord` and the rest are naturally
aligned. Getting this wrong decodes record 0 of every table perfectly and every record
after it as garbage, because only the stride is wrong. Verified strides: ConSymbol 1936,
ConGroup 13696, UserRecord 1120, TradeRecord 224.
- **The transaction-type enum bases at 64.** `TT_BR_ORDER_OPEN` is 75 and `TT_BR_BALANCE`
is 83. Small values produce a bare code 3 "Invalid parameters" that reads like a bad
price or volume.
- **`MtManCreate` wants the DLL's own build number**, from `MtManVersion()` — not the
header's `ManAPIVersion`. A newer header against an older DLL returns a null interface
with no diagnostic. It also returns a non-zero rc on success; only the out-pointer means
anything.
- **Volumes are centilots on the wire** (100 == 1.00 lot). Every tool here takes and
returns lots.
- **A new group or symbol does nothing until the server restarts.** Trades into it are
rejected with code 2 and injected ticks are silently ignored. Editing an *existing*
symbol's margin fields needs no restart.
- **Group rights are resolved at login.** A group created afterwards gives code 7 on money
and trade calls until a re-login; `_run` retries that once automatically.
- **`*Get` vs `*Request`.** The `*Get` family reads a pumping cache that is empty on a
transactional link. This server only uses `*Request`. That is also why `get_quote`
normally answers from the M1 chart rather than a tick.
- 🔴 **An UNFILTERED journal request is capped AND stale.** MT4 serves whole daily log
files: the from/to window selects the *day*, not the time — a 7-second window returned
the identical 143 633 rows spanning ten hours as a 1-hour window. Worse, that result is
capped and contains the **oldest** part of the day, so its newest line measured **78
minutes** behind the server clock, while the same window *with* a substring filter
returned entries from the current second. **Pass `contains` whenever you can** — it is
the only filter the server applies; `match` and `source_col` are applied after the cap.
`get_journal` warns when it detects the cap or a stale tail, and `get_fl_activity` issues
one filtered query per kind for exactly this reason.
- 🔴 **The server's filter is CASE-SENSITIVE.** Measured 2026-09-24: `"fl_xx"` returned
1 905 rows, `"FL_XX"` none. It matches the source column (`ip`, where plugin names live)
as well as the message. `get_journal`'s `contains` is that filter, exact case; its
`match` / `source_col` regexes are case-insensitive and local.
- **The API returns the same lines as the log file.** Compared line by line over a whole
day (2026-09-24, one test server): 0 lines in the file missing from the API. After a
server restart the file itself is out of time order, so a reader that assumes it is
chronological misses the `Startup:` / `Exit:` lines; this API, sorted, does not.
- **Rows do not arrive in chronological order**, so "take the tail" is wrong; both journal
tools sort by parsed timestamp first.
- **Every JournalRequest logs itself** as `'<manager>': journal (<filter>, <from> - <to>)`. Those
are your own footprints and are filtered out — without that they show up as findings.
- **`CalculateMargin` has TWO line shapes.** `CalculateMargin(no rule) symbol: ... crms:
... omrs: ...` means nothing matched; `CalculateMargin(<rule>): '<login>': total position
in <symbol>: ..., margin multiplier: ...` is the ruled form and is the one worth reading.
A regex for one silently misses the other — which briefly made this server report
"no rule" for a position that had demonstrably been multiplied by 3.
- **Journal log types are numbered counter-intuitively, and getting it wrong is silent.**
`LOG_TYPE_STANDARD = 0` ("all except logins") and **`1` = LOGINS ONLY**. This server
originally hardcoded 1 believing it meant "standard", so `get_journal` returned nothing but
`'<manager>': login (...)` lines for weeks — which reads exactly like "the server
logs nothing" and produced a wrong finding. `get_journal` now takes `log_type` and defaults
to `"full"`. Asked correctly, three hours of this stand returns ~143 000 entries.
- **`RateInfo.open` is an integer** (11987 = 119.87) and high/low/close are *shifts from
open*, not absolute prices.
- **A FOREX symbol's name must look like a currency pair.** The server parses the
currencies out of the name; `MYFX01` with `margin_mode=forex` gets zero conversion rates
and every margin calculation silently becomes zero.
- **`margin_mode` numbering differs from MT5.** MT4: 0 forex, 1 cfd, 2 futures, 3 cfdindex,
4 cfdleverage. Do not carry the MT5 map across.
- **Partial close creates a NEW ticket** for the remainder and closes the original.
- **`place_order` can return an error while the order executes** (often 252 on an A-book
route). The tool confirms by rescanning the book for an unseen ticket; never retry on a
bare error without reading the book first, or you double-open.
## Floating leverage
FLL rewrites **`TradeRecord.margin_rate` per position** — it does not touch the account's
leverage. Check `get_positions(...).marginRate`, not `get_accounts(...).leverage`. The
plugin's own arithmetic is written to the journal as
`SymbolExposure::CalculateMargin(rule): ...`, readable with
`get_journal(contains="CalculateMargin", source_col="<your instance>")`.
🔴 **Every plugin instance prices every position.** Measured 2026-09-16: one `EURUSD.xx`
trade produced the identical `CalculateMargin` line from all four instances on the server within a millisecond. So an FL reading is only yours when it comes from your
instance: `get_fl_activity` and `probe_margin` take `plugin=` (default `MT4_FL_PLUGIN`), name
the instance in every event, and warn when an answer mixes several.
MT4 symbol masks in FLL rules use a **forward** slash (`*/EURUSD.xx`) where MT5 uses a
backslash. The wrong one imports cleanly and then never matches.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues