Skip to main content
Glama
pedrobraiti

mcp-ibkr-agent

by pedrobraiti
README.md
<p align="center">
  <img src="assets/banner.png" alt="Valet — an agentic-trading MCP for Interactive Brokers and crypto" width="600">
</p>

<p align="center">
  <strong>Valet</strong> — an MCP toolkit that trades on <strong>Interactive Brokers and crypto exchanges</strong>, so your agent does the legwork and you make the call.
</p>

<p align="center">
  <a href="https://github.com/pedrobraiti/agentic-trading-mcp/actions/workflows/ci.yml"><img src="https://github.com/pedrobraiti/agentic-trading-mcp/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python 3.12+">
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT">
  <img src="https://img.shields.io/badge/status-live--validated-success" alt="Status: live-validated">
</p>

<p align="center">
  <img src="assets/demo.gif" alt="Valet demo: asking the agent to buy $2 of Apple — it previews the cost, places the order, and confirms the fill" width="820">
</p>

**Two MCP servers over one shared safety core**, giving an AI agent (like Claude Code) the ability to trade: **`ibkr`** on **Interactive Brokers** (US stocks, **fractional shares by dollar amount** via `cashQty`) and **`crypto`** on **crypto exchanges** (spot, via CCXT — persistent API key, 24/7). Both expose quotes, balance, positions and **buy/sell** under mirrored tool names.

The investment *decision* (what/when to buy or sell) stays with you and your skill's prompt — e.g. [**Vizier**](https://github.com/pedrobraiti/vizier-trading-skill), the decision-making brain of this stack. This project delivers only the **reliable trading plumbing** — with safety guards on by default.

**Part of a three-piece stack** — brain, senses, hands: [**Vizier**](https://github.com/pedrobraiti/vizier-trading-skill) (the `/vizier` skill) decides, [**Scout**](https://github.com/pedrobraiti/market-research-mcp) researches (62 keyless market-data tools: stocks, crypto, macro, SEC filings), and **Valet** (this repo) executes. Each piece works standalone; together they close the research → decision → execution loop.

> ⚠️ **Not financial advice.** Runs against a *paper* account by default; *live* trading requires explicit opt-in. Use at your own risk.

> **What to expect.** The **crypto** server is the low-friction path: just a persistent API key, 24/7, no gateway — it can run unattended. The **IBKR** server needs a funded **IBKR Pro** account and a **manual browser login about once a day** (IBKR offers no OAuth for retail — an IBKR constraint, not ours), so the stock side isn't fully hands-off. First-time setup is roughly **30–60 min** per venue.

## Architecture

Hexagonal (ports & adapters). The agent talks only to the MCP tools; the safety guards sit on the path of every order; IBKR is an adapter detail:

```mermaid
flowchart LR
    A["AI agent<br/>(/vizier skill)"] -->|MCP tools| B["MCP server<br/>(FastMCP)"]
    B --> C["GuardedBroker<br/>(safety guards)"]
    B --> D["MarketData"]
    C --> E["CPAPI adapters"]
    D --> E
    E -->|REST localhost:5000| F["IBKR Client<br/>Portal Gateway"]
    F --> G["Interactive Brokers"]
```

```
src/
  trading_core/   shared core — domain models, ports, trade journal, the generic
                  GuardedBroker, and the per-venue Capabilities contract
  ibkr_agent/     IBKR adapter (cpapi/ over the Client Portal API) + the `ibkr` MCP server
  crypto_agent/   crypto adapter (adapters/ccxt/, spot) + the `crypto` MCP server
```

Each venue is a thin adapter over `trading_core`; adding a third venue is a new `*_agent`
package, not a change to the core. The diagram above shows the **IBKR** server specifically.
The reasoning behind the key choices lives in [DECISIONS.md](DECISIONS.md).

## Also trades crypto (second MCP server)

This repo is a **monorepo of two execution servers** over one shared safety core
(`trading_core`): the IBKR server above, and a **`crypto`** server (spot, via
[CCXT](https://github.com/ccxt/ccxt)). They are **separate MCP processes** — own login,
own tools, registered separately — and only share code.

Crypto is here because it removes IBKR's structural friction: a **persistent API key**
(no gateway, no daily browser login, no tickle), a **24/7** market, and CCXT behind one
interface for ~100 exchanges. The tools **mirror the IBKR names** (`session_status`,
`get_quote`, `buy`, `sell`, `stop_order`, `close_position`, `open_orders`, …) so one skill
can drive both venues uniformly. Buy-by-value mirrors IBKR's `cashQty` via CCXT's
`createMarketBuyOrderWithCost`. `stop_order` places an **exchange-native trigger order**
(CCXT unified `triggerPrice`) that rests on the exchange and fires with no agent running —
most spot APIs (binance included) only offer stop-LIMIT, so pass `limit_price`; where the
exchange has no native stops the tool refuses cleanly. **Spot-only** by default.

```bash
# register the crypto server (separate from ibkr)
# Windows:      claude mcp add crypto -- /path/to/.venv/Scripts/python.exe -m crypto_agent.server.app
# Linux/macOS:  claude mcp add crypto -- /path/to/.venv/bin/python -m crypto_agent.server.app
python -m crypto_agent.healthcheck   # exchange, mode, balance, a quote
```

Safety mirrors the IBKR posture: **sandbox** (exchange testnet — free keys, no deposit) is
paper-first; the live and dry-run arms are **per-venue** (`CRYPTO_ALLOW_LIVE` /
`CRYPTO_DRY_RUN`, both independent of the IBKR gates — arming IBKR does not arm crypto),
while the policy limits (`MAX_ORDER_VALUE`, `MAX_DAILY_VALUE`, …) are shared. See
[ADR-014](DECISIONS.md) for the rationale and the `CRYPTO_*` keys in
[`.env.example`](.env.example).

## Why fractional matters

Most retail trading APIs force you into whole shares. This project leans on the IBKR Client Portal API's `cashQty` field, which lets you buy by **dollar amount** (e.g. "$50 of AAPL") and get a fractional position — the unlock for dollar-cost averaging, rebalancing, and small accounts. See [DECISIONS.md](DECISIONS.md) for the full rationale.

## Requirements

- **Python 3.12+**
- An **Interactive Brokers** account that is open, funded, and **IBKR Pro** (an API requirement, even to use the associated paper account).
- **Fractional permission** enabled: Client Portal → Settings → Trading → Trading Permissions → Stocks section → check **"Global (Trade in Fractions)"**.
- **IBKR Client Portal Gateway** running locally (Java 8u192+).
- **A dedicated username for the bot**: IBKR allows only **one** brokerage session per username — logging into TWS/mobile with the same user kills the gateway session.

## Installation

```bash
git clone https://github.com/pedrobraiti/agentic-trading-mcp.git
cd agentic-trading-mcp
python -m venv .venv
# Windows (PowerShell): & ".venv\Scripts\Activate.ps1"   (on a policy error: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass)
# Linux/macOS:          source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env   # fill in IBKR_ACCOUNT_ID etc.
```

## Configuration (`.env`)

See `.env.example`. Main keys:

| Key | Default | Description |
|---|---|---|
| `IBKR_API_BASE_URL` | `https://localhost:5000/v1/api` | Client Portal Gateway endpoint |
| `IBKR_ACCOUNT_ID` | — | Account id (e.g. `DU1234567` in paper) |
| `IBKR_TRADING_MODE` | `paper` | A **label only** — it does *not* pick the account. Whether real money is at stake depends on which account you log the gateway into; the ground truth is IBKR's `isPaper` (see `account_type` from `session_status`). |
| `TRADING_ALLOW_LIVE` | `false` | Hard lock: `live` only trades if `true` |
| `TRADING_DRY_RUN` | `true` | Validates but **does not send** orders |
| `TRADING_ALLOW_SHORT` | `false` | Allow a SELL bigger than the held position (opening a short) |
| `MAX_ORDER_VALUE` | `100.0` | Per-order limit (USD) |
| `MAX_DAILY_VALUE` | — | Cumulative daily buy cap (empty = **no cap**; only the per-order `MAX_ORDER_VALUE` then applies). When empty **and** live trading is armed, the server logs a loud startup warning — set a number to bound cumulative daily spend. |
| `DUPLICATE_WINDOW_SECONDS` | `5` | Reject identical orders within this window (`0` = off) |

## Running

### Gateway setup

Valet talks to a local **Client Portal Gateway** — a small Java app from IBKR that bridges to your account. It's the most common place people get stuck, so:

1. Download [`clientportal.gw.zip`](https://download2.interactivebrokers.com/portal/clientportal.gw.zip) (from IBKR's [API page](https://www.interactivebrokers.com/en/trading/ib-api.php)). Requires **Java 8u192+**.
2. Unzip it somewhere outside this repo and start it:

   ```bash
   # Linux/macOS:  bin/run.sh root/conf.yaml
   # Windows:      bin\run.bat root\conf.yaml
   ```

3. Open `https://localhost:5000` and log in with 2FA. Accept the self-signed certificate warning — it's local and expected.
4. **You'll know it worked** when the page says **"Client login succeeds"** and `python -m ibkr_agent.healthcheck` shows `authenticated=True connected=True`.

Keep the gateway running while you use Valet; the session needs a fresh login about once a day (see [Keeping the session alive](#keeping-the-session-alive)). If the browser login misbehaves, see [Login troubleshooting](#login-troubleshooting).

### Register and verify

1. With the gateway running and logged in, register the MCP server with Claude Code:

   ```bash
   # Windows:      claude mcp add ibkr -- /path/to/.venv/Scripts/python.exe -m ibkr_agent.server.app
   # Linux/macOS:  claude mcp add ibkr -- /path/to/.venv/bin/python -m ibkr_agent.server.app
   ```

   (or run it directly to test: `python -m ibkr_agent.server.app`)

   The tools appear in a **new** Claude Code session.

2. **Check the connection** anytime (with the gateway logged in):

   ```bash
   python -m ibkr_agent.healthcheck   # or: ibkr-healthcheck
   ```

   Shows the server version, auth status, account flags (`supportsCashQty`/`supportsFractions`), balance and a quote.

### Keeping the session alive

The gateway session expires (without `/tickle` in ~6 min; lasts at most ~24h; daily maintenance ~01:00 drops it) — and IBKR offers **no** OAuth for retail, so reauth is always a manual browser login.

**While the MCP server is running it keeps its own session warm** — a background `/tickle` runs on the server's lifespan, so you don't need a separate process for interactive use. For when the MCP server isn't running (e.g. scheduled jobs, or just to watch the session), there's also a standalone keep-alive:

```bash
python -m ibkr_agent.keepalive   # or: ibkr-keepalive
```

Both `/tickle` every `TICKLE_INTERVAL_SECONDS` and, when the session drops, emit an **alert** (`[ALERT] Reauthentication required: ...`) telling you to log back in. When merely *connected* without a brokerage session, they try to recover on their own (no new 2FA).

### Login troubleshooting

**`https://localhost:5000` won't load at all** (`ERR_CONNECTION_REFUSED` / "connection refused") — the login page never even appears? That page only exists while the gateway is running, so this means the **gateway isn't up**. Start it (`bin\run.bat root\conf.yaml` on Windows, `bin/run.sh root/conf.yaml` on Linux/macOS) and leave that window open — if it closes, the gateway stops and the port refuses connections again. Only once the page loads do the login steps below apply.

If you log in and approve 2FA but **nothing happens** — the page just sits there and the API stays `authenticated:false`/`connected:false` (sometimes `ssodh/init` returns HTTP 500 / `no bridge`):

- **Restart the gateway clean and log in fresh** — this is what fixes it almost every time. Kill the Java process, start it again, reload `https://localhost:5000`, and log in. An incognito/private tab also helps (stale cookies).
- The login is **not sticky**: each time you need a fresh login, restart the gateway *first*, then log in — don't retry against the already-running gateway.
- **If it still persists**, log out of any other IBKR session (IBKR Mobile or Client Portal web) — only one brokerage session per username is allowed — then restart the gateway and try again.
- The old *launcher* build (2023) is **not** the problem — at runtime the gateway connects to the current backend.

## Exposed tools

`session_status`, `market_status`, `get_quote`, `get_quotes`, `account_summary`, `positions`, `portfolio`, `preview_order`, `buy`, `sell`, `close_position`, `stop_order`, `trailing_stop`, `bracket_order`, `order_status`, `wait_for_fill`, `cancel_order`, `open_orders`, `trade_history`, `reconcile_pending`.

- `get_quotes(symbols)` quotes a whole watchlist in **one** snapshot call (cheaper than one `get_quote` per symbol).
- `preview_order(symbol, side, ...)` estimates an order's **margin impact, commission and warnings** via IBKR's `whatif` — **without sending it** — so the agent can reason about cost before committing.
- `buy` takes `cash_amount` (USD, fractional via `cashQty`) **or** `quantity` (shares, fractional ok). Pass `limit_price` for a **LIMIT** order (market by default; LIMIT needs `quantity`).
- `sell` takes only `quantity` (shares, fractional ok); optional `limit_price` for a LIMIT sell. IBKR does **not** allow selling by dollar amount — `cashQty` is buy-only.
- `close_position(symbol)` closes 100% of a position by trading the exact fractional quantity.
- `stop_order(symbol, side, quantity, stop_price, limit_price?)` places a **STOP** (e.g. a stop-loss) — a market order triggered at `stop_price`, or a STOP-LIMIT if `limit_price` is given.
- `trailing_stop(symbol, side, quantity, trail_amount | trail_percent)` places a **trailing stop** — the trigger follows the price (by a US$ amount or a %), locking in gains as it moves.
- `bracket_order(symbol, quantity, take_profit, stop_loss, ...)` places an entry with attached **take-profit + stop-loss exits (OCO)** — when one exit fills the other is cancelled.
- `order_status(order_id)` reports an order's state, **filled quantity** and average price — use it after `buy`/`sell` to confirm a fill (positions lag right after a trade). `wait_for_fill(order_id, timeout_seconds)` polls until it fills (or is cancelled/rejected), so the agent doesn't orchestrate the retry itself. **`filled_quantity` is always in SHARES**, never dollars: a `cash_amount` order additionally reports the US$ spent in `filled_cash` and sets `is_cash_quantity` (IBKR itself reports that order's fill in *dollars*, in the same field it uses for shares — Valet converts it; see ADR-017).
- `portfolio()` returns a single snapshot: account summary + open positions + total unrealized P&L.
- `trade_history(limit)` returns the audit log of recent order attempts (buys, sells, dry-runs, blocks) — answers "what did my agent do?".
- `reconcile_pending(resolve_missing?)` reconciles **dispatched-but-unconfirmed** orders against IBKR's open orders. After a timeout/crash an order may have landed without its outcome journaled, so the safety layer blocks an identical resend until reconciled: orders found resting are marked resolved; ones not found stay blocked (resending blind could double them).

## Usage example

With the MCP registered, you talk in natural language and the agent uses the tools:

> **You:** *"Buy $50 of AAPL."*
> The agent calls `buy(symbol="AAPL", cash_amount=50)` — IBKR fills a **fractional** order (≈ 0.16 share), no need to pay for a whole share (~$300).

> **You:** *"Close my AAPL position."*
> The agent calls `close_position(symbol="AAPL")`, which reads the exact quantity and sells 100%.

Every tool returns an `{"ok": ..., "data": ...}` envelope. A real example of an executed fractional buy (validated live against an IBKR account):

```json
{
  "ok": true,
  "data": {
    "order_id": "8645012XX",
    "status": "filled",
    "symbol": "AAPL",
    "side": "BUY",
    "message": "Bought 0.0066 AAPL Market, Day"
  }
}
```

> Fractional **buys** use `cashQty` (dollar amount). Fractional **sells** are by share *quantity* — IBKR rejects `cashQty` on sells; that's why `close_position` exists, resolving the exact quantity for you.

## Safety (defaults)

- **paper** by default; **live** blocked unless `TRADING_ALLOW_LIVE=true`.
- **dry-run** on by default (no real order is sent).
- **Know which account is live.** `session_status` and `portfolio` report `account_type` (`"LIVE"`/`"PAPER"`) straight from IBKR's `isPaper` — not the `IBKR_TRADING_MODE` label, which can disagree with the account the gateway is actually logged into. A LIVE account also returns an explicit `warning`. Check it before trading: real-money and paper accounts are never told apart by the config alone.
- **The money-lock is bound to the real account, not the label.** Before sending, the guard verifies IBKR's `isPaper` and the logged-in account and **fails closed** if the configured `IBKR_ACCOUNT_ID` doesn't match, if a real-money account isn't armed with `TRADING_ALLOW_LIVE=true`, or if `IBKR_TRADING_MODE` disagrees with reality — so a mislabelled setup can't quietly trade real money.
- **No accidental shorts:** a SELL larger than the held position is blocked (unless `TRADING_ALLOW_SHORT=true`); exits are never trapped. **No inverted stops:** a stop already on the wrong side of the market (it would fire instantly) is rejected.
- **Buys** above `MAX_ORDER_VALUE` are rejected (it's a spend cap; exits — sells, closes, stop-losses — aren't value-gated, so a large position can always be closed or protected).
- Orders only during regular trading hours (RTH), accounting for **NYSE holidays** (via the `holidays` lib).
- CPAPI confirmation warnings are auto-accepted only through an allow-list; an unknown warning **blocks** the order.
- Optional **daily spend cap** (`MAX_DAILY_VALUE`) across all buys, tracked in the audit log — not just per-order. It is **off by default** (no cap): out of the box only the per-order `MAX_ORDER_VALUE` bounds spending, so many sub-cap buys are unbounded over a day. When it's off **and** live trading is armed (`TRADING_ALLOW_LIVE` / `CRYPTO_ALLOW_LIVE`), the server emits a loud startup warning — set `MAX_DAILY_VALUE` to bound cumulative daily spend.
- The audit-backed caps treat an **`inactive`** order conservatively: CPAPI uses `inactive` for both a dead order and one parked until the open (and a rejected order can arrive as `inactive` too), so it **counts toward** the daily cap and duplicate window on purpose — the fail-safe direction (it may over-block a retry, never over-spend).
- **Duplicate-order guard**: an identical order within `DUPLICATE_WINDOW_SECONDS` is rejected (protects against timeout/retry double-buys).
- Every order attempt (sent, dry-run, or blocked) is written to a local **audit log** (`logs/trades.jsonl`, gitignored).
- Optional **symbol allow/deny list** (`SYMBOL_ALLOWLIST` / `SYMBOL_DENYLIST`) restricts the universe the agent can trade.

The keep-alive can also POST to an optional **webhook** (`REAUTH_WEBHOOK_URL`, e.g. ntfy/Discord) when the session needs a fresh login — a one-way notification, no account data, no trade.

## Development

```bash
python -m pytest -q          # 206 tests, all offline (brokers/exchanges are faked)
python -m ruff check .       # lint
```

See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).

## License

MIT.

TDQS

A3.7/5.0

Scored across 20 tools

Disambiguation5/5

Each tool serves a distinct purpose: account info, quotes, order placement (different order types), order management, preview, history, reconciliation, and session status. Descriptions clearly differentiate even similar-sounding tools like 'open_orders' and 'order_status'.

Naming Consistency5/5

Tool names follow a consistent snake_case verb_noun pattern (e.g., account_summary, cancel_order, get_quote). A few are single verbs (buy, sell) but that is standard for basic actions and does not break consistency.

Tool Count4/5

With 20 tools, the server covers a wide range of trading operations without being excessive. Some tools (e.g., get_quote and get_quotes) could be merged, but the count is still appropriate for the domain.

Completeness4/5

The tool set covers core trading workflows: account info, quotes, various order types, order management, preview, history, and reconciliation. Minor gaps like order modification are absent, but the set is largely complete for common trading tasks.

Maintenance

ActivityStale
ResponsivenessNo issues