Skip to main content
Glama
ruslan-yb

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.