Skip to main content
Glama
jamesnjk

trading-mcp-server

by jamesnjk

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:

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

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

  3. 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:

  1. One-cent disagreement between tools. get_positions converted each MYR position to USD and summed the rounded values (24,504.04); get_portfolio_summary converted 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.

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

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

  1. 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).

  2. Moomoo — install and log into OpenD with a Bursa-enabled account (default port 11111).

  3. Set BROKER_MODE=live and the relevant IBKR_* / MOOMOO_* variables (see core/config.py for 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_performance vs 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
F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • A
    license
    A
    quality
    D
    maintenance
    A 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.
    9
    MIT
  • A
    license
    -
    quality
    D
    maintenance
    An 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.
    6
    MIT
  • F
    license
    -
    quality
    F
    maintenance
    This MCP server interacts with the Interactive Brokers API to fetch portfolio details, enabling portfolio management through natural language.
    65
  • A
    license
    -
    quality
    B
    maintenance
    Read-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.
    1
    MIT

View all related MCP servers

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.

View all MCP Connectors

Latest Blog Posts

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