trading-mcp-server
Click on "Install 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., "@trading-mcp-serverWhat's my total portfolio value across both brokers?"
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.
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.
Related MCP server: claude-tws-connect
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 |
|
| Holdings with quantity, avg cost, market value, unrealized P&L, sector, base-currency value |
| — | Cross-broker merged view: per-broker value, cash, net liquidation, top 5 holdings, FX block |
|
| Fills newest first, with truncation flag and filter echo |
|
| FIFO-matched realized P&L, gross/fees split, per-group ranking, precomputed |
|
| Sector and currency weights with an explicit unclassified share |
| — | 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) |
|
Gateway down (queried directly) |
|
Unknown symbol |
|
Symbol known, no trades in range |
|
Invalid or inverted date range |
|
Empty portfolio |
|
Truncated broker fill history |
|
Unexpected server bug | Contained by the telemetry wrapper, returned as |
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.pyClient-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.txtRun against the frozen fixture book (no gateways needed):
BROKER_MODE=mock .venv/bin/python scripts/smoke_client.pyRun the tests and the eval:
BROKER_MODE=mock .venv/bin/python -m pytest tests -q && BROKER_MODE=mock .venv/bin/python eval/eval_runner.pyLive 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 checkThis server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityDmaintenanceA read-only MCP server that provides access to Charles Schwab account data and market information, including portfolio positions, real-time quotes, options chains, price history, and account balances through AI assistants.9MIT
- Alicense-qualityDmaintenanceAn MCP server that enables interaction with Interactive Brokers TWS/Gateway via natural language for portfolio management and market data retrieval. It provides tools for account summaries, historical data, and a secure two-step confirmation process for placing and canceling orders.6MIT
- Flicense-qualityFmaintenanceThis MCP server interacts with the Interactive Brokers API to fetch portfolio details, enabling portfolio management through natural language.65
- Alicense-qualityBmaintenanceRead-only MCP server that connects LLMs to personal investment accounts (Toss Securities, KIS), market data, SEC filings, and Binance futures for context-aware investment responses.1MIT
Related MCP Connectors
Real SEC, 13F, insider, congress & macro data your AI agent can cite. Hosted MCP, 24 tools.
MCP server exposing the Backtest360 engine API as tools for AI agents.
Hosted MCP for stocks, options, Greeks, brokers, order previews, alerts, and workflows.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jamesnjk/trading-mcp-server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server