milele-prime-mcp
Officialby milelecars
README.md
# milele-mcp
Read-only MCP server for Milele Prime. Lets a verified client connect their own
AI (Claude / ChatGPT) to their own trading account and read account data — no
order execution in phase 1.
## Stack
TypeScript · Node 22 · Express · `@modelcontextprotocol/sdk` · Zod · Postgres
(Supabase) · opaque revocable tokens · Vitest · Railway.
## The core idea: two interfaces are the swap point
Everything in `src/` talks to two interfaces, never to a real API:
- `IdentityProvider` (`src/providers/interfaces/identity.ts`) — Brokeret fills this
- `TradingDataProvider` (`src/providers/interfaces/trading.ts`) — MT5 Manager API fills this
Trading is **real** (MT5, verified live). Identity is still **mock** — see the
warning under Deploy.
## Run it
```bash
npm install
cp .env.example .env # defaults to mock providers, no infra needed
npm run dev # boots on :8080
npm test # 56 tests, fully offline
npm run mt5:test # staged MT5 connectivity probe (needs credentials)
npm run mt5:verify # exercises all 6 read methods against the live server
```
## The tool surface (6 read tools)
| Tool | MT5 command(s) |
|---|---|
| `get_account_summary` | `USER_ACCOUNT_GET` + `USER_GET` + `GROUP_GET` |
| `get_open_positions` | `POSITION_GET_PAGE` |
| `get_pending_orders` | `ORDER_GET_PAGE` |
| `get_trade_history` | `DEAL_GET_PAGE` |
| `get_quote` | `TICK_LAST`, falling back to `TICK_STAT` |
| `get_instrument_details` | `SYMBOL_GET` |
**There is no `get_price_history`.** This access server's Web API exposes no
OHLC/bars command — `CHART_GET`, `CHART_REQUEST`, `BAR_GET`, `RATE_GET`,
`TICK_HISTORY` and ~50 other spellings were all rejected by the live server
while every real command answered (an unknown command drops the socket, which
makes that a reliable negative). Rather than advertise a tool that always
fails, the capability is absent from the interface and the MCP surface.
Restoring it needs a separate feed: the MT5 Gateway/datafeed API, or the
broker's own history service.
The five write tools (`place_market_order`, `place_pending_order`,
`modify_position`, `close_position`, `cancel_order`) are still registered but
**unimplemented against MT5** — the real adapter throws `not wired yet`, so they
return `internal_error`. Phase 2.
## How MT5 is wired
The MT5 "Web API" is **not HTTP/REST** despite the name and the `:443` port. It
is a raw TCP socket protocol, and **the session is the socket** — no cookie, no
bearer token. Framing and the challenge-response handshake live in
`src/providers/real/mt5/protocol.ts`.
`src/providers/real/mt5/connection.ts` holds ONE persistent authenticated
manager socket and multiplexes every query over it:
- **Requests are serialized.** The protocol correlates a response to a request
by an echoed packet number on a single byte stream, so two in-flight requests
would corrupt the framing for both. Concurrent tool calls are safe.
- **Drops auto-recover.** A dead socket loses the session, so a transport
failure re-dials, re-authenticates with exponential backoff, and retries the
read once. A non-zero `RETCODE` is an answer, not an outage, and never
triggers a reconnect.
- **Isolation is unchanged.** The socket authenticates as the *manager* and can
see every account on the server. It queries only the login the adapter hands
it, and the auth gate remains the only thing that decides that login.
## Security model (proven by the test suite)
Every tool call goes through `AuthGate.resolve()` which:
1. validates the token (exists, not revoked),
2. confirms via the CRM that the token's login is owned by that client,
3. confirms the account is KYC-approved and live,
then runs the tool **with the login the gate resolved — never a login from the
caller.** A client cannot pass a login and read another account.
`test/isolation.test.ts` proves: own-account read works, cross-account is
blocked, forged/revoked/garbage tokens are rejected, suspended accounts are
blocked.
---
# Deploy (Railway)
## ⚠️ Identity is still MOCK
This deployment runs `IDENTITY_PROVIDER=mock` with `TRADING_PROVIDER=real`.
That means:
- **Token issuance uses mock fixtures, not real client credentials.** Anyone who
knows a fixture client id and password (`client_A` / `portal-pw-A`, which are
in this repo) can mint a token.
- That token then reads **real money data from the live MT5 server.**
This is acceptable **only** for the owner-testing phase, on accounts the owner
controls. It is NOT safe for real clients. Do not hand the URL to anyone outside
the team, and swap `IDENTITY_PROVIDER=real` (Brokeret) before onboarding a
single real client.
Mock identity also owns fixture logins (50001–50003) that do not exist on the
MT5 server, so every live read would come back empty. Set `MOCK_OWNER_LOGIN` to
a real MT5 login to repoint `client_A` at it for testing.
## Railway environment variables
| Variable | Value | Notes |
|---|---|---|
| `DATABASE_URL` | `postgres://postgres.<ref>:<pass>@aws-0-<region>.pooler.supabase.com:6543/postgres` | Supabase **pooler** (transaction mode, port 6543), NOT `db.<ref>.supabase.co:5432` |
| `IDENTITY_PROVIDER` | `mock` | See the warning above |
| `TRADING_PROVIDER` | `real` | Live MT5 reads |
| `MT5_BASE_URL` | `https://91.243.178.145:443` | Scheme is informational — only host:port is used |
| `MT5_MANAGER_LOGIN` | *(manager login)* | Secret |
| `MT5_MANAGER_PASSWORD` | *(manager password)* | Secret |
| `MT5_TEST_LOGIN` | *(read-only test account)* | Used by `mt5:verify`, not by the server |
| `PORT` | Railway injects this | The app reads it; don't hardcode |
| `PUBLIC_BASE_URL` | `https://<app>.up.railway.app` | Handed to clients as their `/mcp` URL |
| `MOCK_OWNER_LOGIN` | *(a real MT5 login)* | Optional. Repoints `client_A` for owner testing |
Secrets live **only** in Railway environment variables. `.env` is gitignored and
has never been committed — verify with `git log --all -- .env` (empty).
## Outbound network — the thing most likely to break
Railway must reach **`91.243.178.145:443` over raw TCP**. This is not an HTTPS
request, so anything that assumes HTTP (proxies, L7 egress filtering) will not
work.
If the MT5 side enforces an **IP allowlist**, Railway's egress IP must be
whitelisted. Railway egress IPs are not stable on the free/hobby tiers — a
static egress IP generally needs a paid plan or an outbound proxy. Confirm this
with the MT5 admin before assuming it works.
An allowlist that *drops* packets (the common case) does not produce a
connection-refused error; it looks like a silent timeout. `/health` reports both
the same way, with an explicit hint.
## Health check
`GET /health`:
```json
{
"ok": true,
"identity": "mock",
"trading": "real",
"mt5": {
"configured": true,
"endpoint": "91.243.178.145:443",
"state": "connected",
"transport": "plain-tcp",
"lastConnectedAt": "2026-07-31T08:14:33.304Z"
}
}
```
`mt5.state` is one of `connected`, `disconnected` (was up, socket since
dropped — normal when idle, the next call re-dials), `never_connected`, or
`error`. On failure, `mt5.lastError` carries the reason:
```json
{
"configured": true,
"endpoint": "198.51.100.99:443",
"state": "error",
"lastError": "could not establish an authenticated MT5 session to 198.51.100.99:443 after 5 attempts: Error: socket idle timeout after 3000ms — the MT5 access server refused, dropped or ignored the TCP connection. Check outbound egress from this host, and if the access server enforces an IP allowlist, this host's egress IP must be whitelisted."
}
```
Two deliberate choices:
- **`ok` reflects this service, not the broker.** A refused MT5 socket must not
fail Railway's health check and trigger a restart loop — the server is healthy
and serving; the upstream is not. Point Railway's health check at `/health`
and read `mt5.state` separately.
- **`/health` never dials.** It reports the last observed state, so the check
stays cheap and cannot hang on a dead broker. Use `GET /health?probe=1` to
force a live dial attempt when diagnosing egress.
The MT5 socket is opened in the background at boot, so `/health` has a real
answer within a second or two of startup without waiting for the first tool
call. **A broker that is unreachable at boot never stops the server from
starting** — the failure is recorded and surfaced, not fatal.
## Deploy checklist
1. Set every variable in the table above in Railway.
2. Run `db/migrations/001_init.sql` against Supabase (`npm run migrate`).
3. Deploy. Confirm the boot log shows `mt5_boot_state` with `"state":"connected"`.
4. `curl https://<app>.up.railway.app/health` → `mt5.state` should be
`connected` (or `disconnected` if it has been idle — that is fine).
5. If `state` is `error`, read `lastError`. A timeout almost always means egress
or an IP allowlist, not a bug.
6. Mint a token via `POST /connect/initiate` and call `get_account_summary`.
## Build sequence (status)
- [x] Server skeleton + config + factory (swap point)
- [x] Two provider interfaces
- [x] Mock implementations of both
- [x] Token service (Postgres-backed; falls back to in-memory without `DATABASE_URL`)
- [x] Authorization gate + audit log + tool runner
- [x] Cross-account isolation test passing
- [x] All 6 read tools
- [x] MCP SDK streamable-HTTP transport
- [x] CRM "Connect AI Assistant" portal flow — `POST /connect/initiate|revoke`, `GET /connect/activity`
- [x] Real MT5 trading adapter (read side), verified live
- [ ] Real Brokeret identity adapter ← **the blocker for real clients**
- [ ] Write/execution tools against MT5 (phase 2, needs compliance sign-off)
- [ ] Price history — needs a datafeed the Web API doesn't provide
## Adding a tool
1. Add the method to `TradingDataProvider` + both mock and real impls.
2. Add a tool function in `src/tools/index.ts` using `runTool(...)` (gate + audit are automatic).
3. Register it in `buildMcpServer` in `src/server.ts`.
4. Add an isolation assertion for it in `test/isolation.test.ts`.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues