trading-mcp-server
A read-only MCP server that exposes Interactive Brokers and Moomoo brokerage accounts as tools, enabling natural-language queries across positions, trade history, realized P&L, and sector exposure in one merged view.
README
Trading MCP Server
A read-only Model Context Protocol server that exposes two real brokerage accounts — Interactive Brokers (US equities, USD) and Moomoo (Bursa Malaysia, MYR) — as tools an LLM client can query and reason over.
Ask Claude Desktop "what was my biggest losing trade this year, and how much of my portfolio is still in ringgit?" and it answers from live account data, in one currency, across two brokers that share no common data model.
The problem
Two brokers, two APIs, two currencies, and no shared vocabulary between them. IBKR reports fills through a socket API with a few days of execution history; Moomoo reports Bursa deals through a local gateway with no commission field. Neither reports realized P&L over an arbitrary window, and neither returns a sector taxonomy that survives being merged with the other.
Answering "how did I do this year?" therefore isn't a data-fetching problem, it's a normalization problem — and the interesting engineering is in deciding what the server should compute rather than pass through.
Architecture
┌──────────────────┐ MCP (stdio) ┌────────────────────────────────────────┐
│ Claude Desktop │◄────────────────►│ server.py │
│ (or any MCP │ tools/list │ 6 tools, schemas + model-facing docs │
│ client) │ tools/call │ telemetry: logs/tool_calls.jsonl │
└──────────────────┘ └───────────────┬────────────────────────┘
│ ToolContext
┌───────────────▼────────────────────────┐
│ tools/ │
│ positions · trade_history · pnl · │
│ exposure · health │
└───────────────┬────────────────────────┘
│
┌──────────────────────────────┼───────────────────────┐
│ │ │
┌────────▼────────┐ ┌──────────▼─────────┐ ┌─────────▼────────┐
│ core/ │ │ brokers/registry │ │ core/sectors │
│ pnl (FIFO) │ │ mock ⇄ live │ │ core/fx │
│ models · dates │ └──────────┬─────────┘ └──────────────────┘
└─────────────────┘ │
┌────────────────┼────────────────┐
│ │ │
┌────────▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
│ brokers/ibkr │ │brokers/moomoo│ │ fixture_adapter│
│ ib_async │ │ futu-api │ │ frozen JSON │
└────────┬──────┘ └───────┬──────┘ └────────────────┘
│ │
TWS / IB Gateway Moomoo OpenD
(127.0.0.1:7497) (127.0.0.1:11111)
The layering rule: only brokers/* knows a vendor API exists. Everything
above it works on the normalized Position / Trade types in core/models.py,
which is what makes a cross-broker tool possible at all.
Why MCP rather than a function-calling wrapper
A plain OpenAI/Anthropic function-calling script would answer the same questions. MCP buys three things that matter once this stops being a demo:
- Client-agnostic. The same server process works in Claude Desktop, Claude Code, or any other MCP client without touching the server. The tool contract is the integration surface, not a particular vendor's SDK.
- Discovery. Tools, schemas and descriptions are served at runtime via
tools/list. Adding a broker or a tool needs no client-side change, which is the difference between a demo and something a second person can use. - Process isolation. The server owns the broker sockets and credentials; the client only ever sees tool results. Nothing about the account reaches the model except what a tool chose to return.
Tools
| Tool | Arguments | Returns |
|---|---|---|
get_positions |
broker? |
Holdings with quantity, avg cost, market value, unrealized P&L, sector, base-currency value |
get_portfolio_summary |
— | Cross-broker merged view: per-broker value, cash, net liquidation, top 5 holdings, FX block |
get_trade_history |
symbol?, start_date?, end_date?, broker?, limit=100 |
Fills newest first, with truncation flag and filter echo |
get_realized_pnl |
start_date?, end_date?, symbol?, broker?, group_by=symbol|broker|none |
FIFO-matched realized P&L, gross/fees split, per-group ranking, precomputed biggest_winner/biggest_loser, and the closed-lot audit trail |
get_portfolio_exposure_by_sector |
broker? |
Sector and currency weights with an explicit unclassified share |
get_server_health |
— | Gateway reachability plus a positions-vs-fills reconciliation check |
All dates are absolute ISO (YYYY-MM-DD) and inclusive at both ends.
Design decisions
Realized P&L is computed server-side, not read from the broker. This is the
central judgment call. IBKR exposes a realized-P&L field, but it is scoped to the
current session, uses IBKR's own lot convention, and cannot be re-cut over an
arbitrary date range; Moomoo exposes nothing comparable. Two broker-reported
figures on two different bases cannot be added together and called a portfolio
number. So core/pnl.py matches lots FIFO from the fills, defines net P&L once
(gross minus the closing commission plus the prorated opening commission),
attributes each lot to its closing date, and returns the closed lots
themselves so any figure can be traced back to the fills behind it. The cost is
that the answer is only as good as the fill history the broker returns — which is
why get_server_health reconciles reported positions against the inventory the
fills imply, and says so when they disagree.
The date filter selects closed lots, not fills. A lot closed in 2026 has to match against the buy that opened it in 2024, so the full history is always replayed before filtering. Filtering fills first would silently produce a wrong cost basis — the kind of bug that returns a plausible number.
Relative dates are the model's job, absolute dates are the server's. The
server rejects "last quarter" outright. That split means a wrong answer is
attributable: either the model resolved the period wrong (visible in
logs/tool_calls.jsonl) or the server computed the range wrong. If the server
guessed at relative periods too, those two failure modes would be
indistinguishable in an eval.
Sectors come from a curated map, not from the brokers. IBKR's contract
details give an inconsistent industry/category/subcategory triple; Moomoo's Bursa
snapshots give nothing. Neither can key a merged breakdown. brokers/fixtures/sectors.json
maps BROKER:SYMBOL onto GICS sector names, and anything missing lands in an
explicit Unclassified bucket that is reported as a percentage rather than
quietly shrinking every other sector.
Errors are results, not exceptions. Every tool returns
{"status": "error", "error": {"code", "message", "hint"}} rather than a
protocol-level failure. The hint is written for the model — "start OpenD and
retry", "widen the date range", "Bursa symbols are numeric codes" — because that
text is the only recovery signal it gets. An empty result and a broken gateway
are deliberately different shapes: a true zero comes back as status: ok with a
note saying it is a real answer, and a down gateway comes back as
broker_unavailable pointing at get_server_health.
FX totals are summed at full precision and rounded once. Rounding each leg
first made get_positions disagree with get_portfolio_summary by one cent on
the same portfolio. Caught by a test, not by eyeballing output; see the eval
notes below.
Failure modes handled
| Situation | Behavior |
|---|---|
| Gateway down (one broker) | get_portfolio_summary returns status: partial, names the unreachable broker, and totals only what it reached |
| Gateway down (queried directly) | broker_unavailable with the specific restart instruction for that gateway |
| Unknown symbol | symbol_not_found, with the list of symbols the server does know |
| Symbol known, no trades in range | status: ok, empty list, note telling the model to widen the range before concluding "no trading" |
| Invalid or inverted date range | invalid_input before any gateway work happens |
| Empty portfolio | status: ok with an explicit "this is a valid empty portfolio, not an error" note |
| Truncated broker fill history | get_server_health flags positions that disagree with the inventory implied by the fills |
| Unexpected server bug | Contained by the telemetry wrapper, returned as internal_error with the failing arguments logged |
Every tool call is logged to logs/tool_calls.jsonl — name, arguments, latency,
status, result summary. That log is how the eval attributes a bad answer to tool
selection versus tool output.
Eval
Full question set, hand-computed ground truth and the arithmetic behind it:
eval/questions.md. Results table:
eval/results.md.
16 questions covering cross-broker aggregation, cross-currency conversion, period P&L, partial-lot commission proration, a true-zero result, and an unknown symbol. Ground truth was computed by hand from the fixture book before any of it was run through the server.
Tools-mode pass (data layer): 16/16. This runs the reference tool plan for each question directly against the tool functions and checks the number against ground truth. It validates FIFO matching, FX, filters and error paths. It says nothing about whether a model picks the right tool.
BROKER_MODE=mock .venv/bin/python eval/eval_runner.py
Client-mode pass (tool selection): not yet run. Measuring whether the model
picks the right tool and resolves "last quarter" correctly requires driving the
questions through Claude Desktop and recording what happened —
eval/runs/TEMPLATE.json, then --mode client. That pass is a manual step and
is honestly reported here as pending rather than implied by the 16/16 above.
What the first iteration found
Two defects, both surfaced by tests rather than by reading output:
-
One-cent disagreement between tools.
get_positionsconverted each MYR position to USD and summed the rounded values (24,504.04);get_portfolio_summaryconverted the aggregate (24,504.05). Two tools, same portfolio, different totals — the sort of inconsistency that makes a user stop trusting every other number. Fixed by summing at full precision and rounding once at the boundary (core/fx.py). Ground truth, computed by hand, agreed with the fixed version. -
Mock and live disagreed on caveats. The live Moomoo adapter reports that Bursa deals carry no commission data, so its P&L is gross of fees; the fixture adapter dropped that caveat, meaning the eval validated an answer the live server would have qualified differently. The fixture now carries the same note through the same code path.
-
A missing gateway hung the call instead of failing it. Exercising live mode with neither gateway running, the futu client's internal retry loop never returned, so the tool call blocked indefinitely — the worst possible failure for an LLM client, which gets no error to reason about and simply stalls. Both adapters now TCP-precheck the gateway (
brokers/_net.py) and fail in ~0.2s with a restart instruction. Found only by running the live path with nothing to connect to, which is a case fixtures can never cover.
The first is the more interesting one for the design: it is invisible in any single tool's output and only appears when two tools are asked the same question two ways — which is exactly what an LLM client does. The third is the reminder that mock mode validates logic, not integration.
One further live-mode hazard, fixed pre-emptively rather than found: the futu SDK prints to stdout, and under stdio transport stdout is the JSON-RPC stream, so a single vendor banner would corrupt the protocol and drop the connection. All futu calls run inside a redirect that routes their output to stderr.
Setup
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
Run against the frozen fixture book (no gateways needed):
BROKER_MODE=mock .venv/bin/python scripts/smoke_client.py
Run the tests and the eval:
BROKER_MODE=mock .venv/bin/python -m pytest tests -q && BROKER_MODE=mock .venv/bin/python eval/eval_runner.py
Live mode
- IBKR — install TWS or IB Gateway, enable Configure → API → Settings → Enable ActiveX and Socket Clients, and note the port (7497 TWS paper, 7496 TWS live, 4002/4001 Gateway paper/live).
- Moomoo — install and log into OpenD with a Bursa-enabled account (default port 11111).
- Set
BROKER_MODE=liveand the relevantIBKR_*/MOOMOO_*variables (seecore/config.pyfor the full list).
Claude Desktop
Add to claude_desktop_config.json (see claude_desktop_config.example.json):
{
"mcpServers": {
"trading-portfolio": {
"command": "/absolute/path/to/trading-mcp-server/.venv/bin/python",
"args": ["/absolute/path/to/trading-mcp-server/server.py"],
"env": { "BROKER_MODE": "mock" }
}
}
}
Restart Claude Desktop; the six tools appear under the connector. Switch env
to "BROKER_MODE": "live" once both gateways are running.
Safety
Read-only by construction. The IBKR socket is opened with readonly=True, so
the connection itself cannot transmit an order, and no order-placement code path
exists anywhere in this repo. Trade execution is an explicit non-goal.
Nothing sensitive is committed. No credentials, tokens, account numbers or
API keys are in this repository. Hosts, ports and account identifiers are read
from environment variables at startup; the committed defaults are the published
vendor defaults. The fixture book in brokers/fixtures/ is synthetic — invented
positions, prices and fills, with placeholder account ids — not a sanitized copy
of a real account. logs/ is gitignored.
Non-goals
- No trade execution. Read-only, deliberately.
- No hosting or deployment. It runs locally next to the gateways it reads.
- No custom frontend. The LLM client is the interface.
- No benchmark comparison (
compare_performancevs S&P 500 / KLCI) — it needs a market-data source and a return methodology (time-weighted vs money-weighted) that would be its own piece of work, and a hand-waved version would be the least trustworthy number in the repo.
Layout
server.py MCP entrypoint: tool registration, schemas, model-facing docs
core/ models.py normalized Position / Trade / RealizedLot
pnl.py FIFO lot matching
fx.py currency conversion
sectors.py curated symbol → sector map
dates.py ISO parsing and range validation
errors.py domain errors with model-facing hints
telemetry.py per-call JSONL logging
config.py environment configuration
brokers/ base.py adapter contract
ibkr.py ib_async adapter (readonly socket)
moomoo.py futu-api adapter (OpenD)
fixture_adapter.py frozen-book adapter for mock mode
registry.py mock ⇄ live selection
fixtures/ synthetic book + sector map
tools/ the six tools, as plain functions
eval/ question set, ground truth, runner, results
tests/ 48 tests: FIFO engine, tools, failure modes, FX/dates
scripts/smoke_client.py minimal MCP client for an end-to-end check
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.