Skip to main content
Glama
ruslan-yb

mcp-mt4-manapi

by ruslan-yb

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

the ctypes layer: structs, vtable slots, one connection class

server.py

the FastMCP tools

Resuming, or new to this? Read 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

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:

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.

Related MCP server: AceDataCloud MCP Server

Configuration

All settings are environment variables; nothing is hardcoded and no credential is committed. See .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 (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

# 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    C
    quality
    A
    maintenance
    Manages your AceDataCloud account through the platform management API, enabling balance checks, usage/spend lookup, API key management, order creation/payment, announcement publishing, and more.
    134
    MIT
  • A
    license
    C
    quality
    A
    maintenance
    Enables interaction with Yandex Metrika's Reports and Management APIs, providing tools for querying analytics data and managing counters, goals, filters, and other settings.
    68
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Enables management of Minecraft servers through the MCSManager panel API, including listing instances, checking status, starting/stopping/restarting/force-stopping, sending console commands, and waiting for status changes.
    10
    MIT