mcp-mt4-manapi
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-mt4-manapiShow open orders for login 1234 on the lab environment."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
the ctypes layer: structs, vtable slots, one connection class | |
the FastMCP tools |
Resuming, or new to this? Read
HANDOFF.mdfirst — 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 inYou 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=77Switch 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 |
| your group prefix, | group writes are refused |
| your symbol suffix, | symbol writes are refused |
| your plugin instance, | 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 |
| the environment to start on |
| one environment: |
| the unnamed |
| any setting below, for one environment only |
| path to |
| group prefixes this server may write — no default, writes refused until set |
| symbol suffixes this server may write — no default |
| default |
| 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 |
|
|
|
engineer B |
|
|
|
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 |
| ~26 500 |
| ~5 200 |
| ~1 200 |
| ~10 800 |
| ~1 200 |
| ~290 |
| ~650 |
| ~260 |
| ~210 |
| ~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.pyA 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
TradeRecordandTradeTransInfoare#pragma pack(1);ConSymbol,ConGroup,UserRecordand 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_OPENis 75 andTT_BR_BALANCEis 83. Small values produce a bare code 3 "Invalid parameters" that reads like a bad price or volume.MtManCreatewants the DLL's own build number, fromMtManVersion()— not the header'sManAPIVersion. 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;
_runretries that once automatically.*Getvs*Request. The*Getfamily reads a pumping cache that is empty on a transactional link. This server only uses*Request. That is also whyget_quotenormally 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
containswhenever you can — it is the only filter the server applies;matchandsource_colare applied after the cap.get_journalwarns when it detects the cap or a stale tail, andget_fl_activityissues 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'scontainsis that filter, exact case; itsmatch/source_colregexes 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.CalculateMarginhas 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") and1= LOGINS ONLY. This server originally hardcoded 1 believing it meant "standard", soget_journalreturned nothing but'<manager>': login (...)lines for weeks — which reads exactly like "the server logs nothing" and produced a wrong finding.get_journalnow takeslog_typeand defaults to"full". Asked correctly, three hours of this stand returns ~143 000 entries.RateInfo.openis 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;
MYFX01withmargin_mode=forexgets zero conversion rates and every margin calculation silently becomes zero.margin_modenumbering 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_ordercan 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
Related MCP Connectors
Connect any MCP client to MetaTrader 4/5 to read prices, manage positions, and place trades.
MCP server for OpenMM — exposes market data, account, trading, and strategy tools to AI agents
Remote MCP for KOYN FX: account, markets, and TradeLocker tools over OAuth.
Live LinReg fan charts, 64-setup playbook, 11-section MTF TA. Remote MCP, no API key.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables AI agents to manage Minecraft servers through MCSManager, including instance control (start/stop/restart), file management, scheduled tasks, user management, and backup operations.5-
- AlicenseCqualityAmaintenanceManages your AceDataCloud account through the platform management API, enabling balance checks, usage/spend lookup, API key management, order creation/payment, announcement publishing, and more.134MIT
- AlicenseCqualityAmaintenanceEnables interaction with Yandex Metrika's Reports and Management APIs, providing tools for querying analytics data and managing counters, goals, filters, and other settings.68MIT
- AlicenseAqualityCmaintenanceEnables 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.10MIT