Skip to main content
Glama

Interactive Brokers MCP Server

by atilcan
server.py15.4 kB
import math import os from typing import Any, Dict, Optional from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from .ib import ib_manager def _to_float(value: Any) -> Optional[float]: if value is None: return None try: f = float(value) except Exception: return None if math.isnan(f) or math.isinf(f): return None return f load_dotenv() server = FastMCP( name="ib-mcp", instructions=( "Local MCP server that connects only to IB Gateway to fetch stock and options data." ), ) @server.tool() def get_stock_quote(symbol: str, exchange: str = "SMART", currency: str = "USD") -> Dict[str, Any]: contract = ib_manager.stock(symbol, exchange, currency) data = ib_manager.req_best_effort(contract) return { "symbol": symbol.upper(), "price": _to_float(data.get("last")), "volume": data.get("volume"), "timestamp": data.get("timestamp"), } @server.tool() def get_option_quote( symbol: str, right: str, strike: float, lastTradeDateOrContractMonth: str, exchange: str = "SMART", currency: str = "USD", ) -> Dict[str, Any]: contract = ib_manager.option(symbol, lastTradeDateOrContractMonth, float(strike), right, exchange, currency) data = ib_manager.req_best_effort(contract) bid = _to_float(data.get("bid")) ask = _to_float(data.get("ask")) last = _to_float(data.get("last")) return { "symbol": symbol.upper(), "contract": f"{symbol.upper()} {lastTradeDateOrContractMonth} {strike} {right.upper()}", "bid": bid, "ask": ask, "last": last, "timestamp": data.get("timestamp"), } @server.tool() def list_options( symbol: str, months_ahead: int = 3, right: str = "BOTH", strikes_around: int = 10, include_weeklies: bool = True, exchange: str = "SMART", currency: str = "USD", ) -> Dict[str, Any]: items = ib_manager.list_option_contracts( symbol=symbol, months_ahead=months_ahead, right=right, strikes_around=strikes_around, include_weeklies=include_weeklies, exchange=exchange, currency=currency, ) return {"count": len(items), "items": items} @server.tool() def get_option_quotes_bulk( symbol: str, months_ahead: int = 3, right: str = "BOTH", strikes_around: int = 10, include_weeklies: bool = True, exchange: str = "SMART", currency: str = "USD", max_contracts: int = 200, ) -> Dict[str, Any]: rows = ib_manager.get_option_quotes_bulk( symbol=symbol, months_ahead=months_ahead, right=right, strikes_around=strikes_around, include_weeklies=include_weeklies, exchange=exchange, currency=currency, max_contracts=max_contracts, ) return {"count": len(rows), "rows": rows} @server.tool() def analyze_bull_call_spreads( symbol: str, min_months: int = 4, max_months: int = 12, min_short_cushion_pct: float = 30.0, min_roi_pct: float = 5.0, strikes_around: int = 9999, include_weeklies: bool = False, deep_long_factor: float = 0.8, exchange: str = "SMART", currency: str = "USD", max_contracts: int = 600, ) -> Dict[str, Any]: from datetime import datetime, timezone, timedelta def parse_exp(s: str): try: if len(s) == 8: return datetime.strptime(s, "%Y%m%d").replace(tzinfo=timezone.utc) if len(s) == 6: dt = datetime.strptime(s + "01", "%Y%m%d").replace(tzinfo=timezone.utc) if dt.month == 12: dt2 = dt.replace(year=dt.year + 1, month=1, day=1) else: dt2 = dt.replace(month=dt.month + 1, day=1) return dt2 - timedelta(days=1) except Exception: return None return None # Spot spot_info = ib_manager.req_best_effort(ib_manager.stock(symbol, exchange, currency)) spot = spot_info.get("last") if spot is None: return {"error": f"No spot for {symbol}"} spot_f = float(spot) # Bulk quotes # Use contract-details-based enumeration to avoid invalid combos and speed up spot_info = ib_manager.req_best_effort(ib_manager.stock(symbol, exchange, currency)) spot = spot_info.get("last") spot_f = float(spot) # Expirations window exps_all = ib_manager.get_option_expirations(symbol, exchange) from datetime import datetime, timezone, timedelta now = datetime.now(timezone.utc) min_exp = now + timedelta(days=30 * max(1, min_months)) max_exp_dt = now + timedelta(days=30 * max(1, max_months)) def parse_exp(s: str): try: if len(s) == 8: return datetime.strptime(s, "%Y%m%d").replace(tzinfo=timezone.utc) if len(s) == 6: dt = datetime.strptime(s + "01", "%Y%m%d").replace(tzinfo=timezone.utc) if dt.month == 12: dt2 = dt.replace(year=dt.year + 1, month=1, day=1) else: dt2 = dt.replace(month=dt.month + 1, day=1) return dt2 - timedelta(days=1) except Exception: return None return None exps = [e for e in exps_all if (parse_exp(e) and min_exp <= parse_exp(e) <= max_exp_dt)] # Focus strikes around spot for speed; deep legs need <= deep_long_factor*spot, shorts need >= spot*(1+min_cushion) strike_min = 0.0 strike_max = spot_f * (1 + min_short_cushion_pct / 100.0) * 1.2 details = ib_manager.list_option_details_filtered( symbol=symbol, expirations=exps, right="C", exchange=exchange, strike_min=strike_min, strike_max=strike_max, max_per_expiry=120, ) quotes = ib_manager.get_quotes_for_conids(details) # Filter expirations by months now = datetime.now(timezone.utc) min_exp = now + timedelta(days=30 * max(1, min_months)) max_exp_dt = now + timedelta(days=30 * max(1, max_months)) rows = [r for r in quotes if (parse_exp(r["expiry"]) and min_exp <= parse_exp(r["expiry"]) <= max_exp_dt)] # Prepare map per expiry from collections import defaultdict by_exp: Dict[str, Dict[float, Dict[str, Any]]] = defaultdict(dict) for r in rows: try: by_exp[r["expiry"]][float(r["strike"])] = r except Exception: continue results = [] deep_long_threshold = deep_long_factor * spot_f min_short_cushion = min_short_cushion_pct / 100.0 min_roi = min_roi_pct / 100.0 for expiry, strike_to_quote in by_exp.items(): strikes = sorted(strike_to_quote.keys()) for i, long_strike in enumerate(strikes): if long_strike > deep_long_threshold: continue long_last = strike_to_quote[long_strike].get("last") if long_last is None: continue for short_strike in strikes[i + 1 :]: cushion = (short_strike - spot_f) / spot_f if cushion < min_short_cushion: continue short_last = strike_to_quote[short_strike].get("last") if short_last is None: continue try: net_debit = float(long_last) - float(short_last) except Exception: continue if net_debit <= 0: continue width = short_strike - long_strike max_profit = width - net_debit if max_profit <= 0: continue roi = max_profit / net_debit if roi < min_roi: continue dt_obj = parse_exp(expiry) dte = (dt_obj - now).days if dt_obj else None results.append( { "expiry": expiry, "dte": dte, "long_strike": long_strike, "short_strike": short_strike, "long_last": _to_float(long_last), "short_last": _to_float(short_last), "net_debit": _to_float(net_debit), "width": _to_float(width), "max_profit": _to_float(max_profit), "roi_pct": _to_float(roi * 100), "cushion_pct": _to_float(cushion * 100), } ) results = [r for r in results if r["roi_pct"] is not None] results.sort(key=lambda r: r["roi_pct"], reverse=True) top = results[:20] return { "symbol": symbol.upper(), "spot": _to_float(spot_f), "total": len(results), "top20": top, } @server.tool() def get_option_quotes_for_date( symbol: str, expiry_yyyymmdd: str, right: str = "BOTH", strikes_around: int = 10, exchange: str = "SMART", currency: str = "USD", max_contracts: int = 300, timeout_sec: Optional[float] = 8.0, enable_fallback: bool = False, prefer_valid_contracts: bool = True, ) -> Dict[str, Any]: """Return quotes for all options of a symbol that expire on a specific YYYYMMDD date. By default enumerates only valid contracts via contract details to avoid security-definition errors, then fetches batched snapshots. Historical fallback can fill missing last. """ items = [] contracts = [] snapshots = [] if prefer_valid_contracts: # Enumerate valid contracts for the expiry using contract details exps = [expiry_yyyymmdd] rights = ([right.upper()] if right.upper() in ("C", "P") else ["C", "P"]) details: list[dict] = [] for r in rights: details.extend( ib_manager.list_option_details_filtered( symbol=symbol, expirations=exps, right=r, exchange=exchange, strike_min=None, strike_max=None, max_per_expiry=0, # no cap ) ) if max_contracts and len(details) > max_contracts: details = details[:max_contracts] # Build from details items = [ { "symbol": d["symbol"], "expiry": d["expiry"], "strike": d["strike"], "right": d["right"], "exchange": d.get("exchange", exchange), "currency": d.get("currency", currency), "conId": d.get("conId"), "localSymbol": d.get("localSymbol"), "tradingClass": d.get("tradingClass"), } for d in details ] # Build Option contracts (set conId/localSymbol when present) and request snapshots for it in items: c = ib_manager.option( it["symbol"], it["expiry"], it["strike"], it["right"], it.get("exchange", exchange), it.get("currency", currency) ) try: if it.get("conId"): c.conId = int(it.get("conId")) except Exception: pass if it.get("localSymbol"): c.localSymbol = it.get("localSymbol") if it.get("tradingClass"): c.tradingClass = it.get("tradingClass") contracts.append(c) snapshots = ib_manager.req_snapshots_batch(contracts, timeout_sec=timeout_sec, chunk_size=80) if items else [] else: # Fallback to fast chain enumeration (may contain invalid combos) items = ib_manager.list_option_contracts( symbol=symbol, months_ahead=2, right=right, strikes_around=strikes_around, include_weeklies=True, exchange=exchange, currency=currency, ) items = [it for it in items if it.get("expiry") == expiry_yyyymmdd] if max_contracts and len(items) > max_contracts: items = items[:max_contracts] contracts = [ ib_manager.option( it["symbol"], it["expiry"], it["strike"], it["right"], it.get("exchange", exchange), it.get("currency", currency) ) for it in items ] snapshots = ib_manager.req_snapshots_batch(contracts, timeout_sec=timeout_sec, chunk_size=80) if items else [] rows = [] for it, snap in zip(items, snapshots): rows.append({ "contract": f"{it['symbol']} {it['expiry']} {it['strike']} {it['right']}", "type": ("Call" if it["right"].upper() == "C" else ("Put" if it["right"].upper() == "P" else it["right"].upper())), "bid": _to_float(snap.get("bid")), "ask": _to_float(snap.get("ask")), "last": _to_float(snap.get("last")), }) # Historical fallback for missing last if requested if enable_fallback and items: for idx, row in enumerate(rows): if row.get("last") is not None: continue try: data = ib_manager.req_best_effort(contracts[idx]) except Exception: data = {} if row.get("last") is None and data.get("last") is not None: row["last"] = _to_float(data.get("last")) # Sort by strike then type for readability def _parse_strike(contract_str: str) -> float: try: return float(contract_str.split()[2]) except Exception: return 0.0 rows.sort(key=lambda r: (_parse_strike(r["contract"]), r["type"])) return { "symbol": symbol.upper(), "expiry": expiry_yyyymmdd, "count": len(rows), "rows": rows, } @server.tool() def get_option_quotes_for_date_table( symbol: str, expiry_yyyymmdd: str, right: str = "BOTH", strikes_around: int = 10, exchange: str = "SMART", currency: str = "USD", max_contracts: int = 300, timeout_sec: Optional[float] = 8.0, enable_fallback: bool = False, ) -> str: """Return a markdown table: Contract | Type(Call-Put) | Bid | Ask | Last""" data = get_option_quotes_for_date( symbol=symbol, expiry_yyyymmdd=expiry_yyyymmdd, right=right, strikes_around=strikes_around, exchange=exchange, currency=currency, max_contracts=max_contracts, timeout_sec=timeout_sec, enable_fallback=enable_fallback, ) rows = data.get("rows", []) lines = [ "| Contract | Type(Call-Put) | Bid | Ask | Last |", "|---|---|---:|---:|---:|", ] for r in rows: def f(v: Any) -> str: if v is None: return "" try: fv = float(v) if fv != fv: return "" return f"{fv:.2f}" except Exception: return str(v) lines.append( f"| {r.get('contract','')} | {r.get('type','')} | {f(r.get('bid'))} | {f(r.get('ask'))} | {f(r.get('last'))} |" ) return "\n".join(lines) if __name__ == "__main__": try: ib_manager.connect() except Exception: pass server.run()

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/atilcan/ib-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server