Options Trading MCP
A guarded Model Context Protocol server that gives an AI client read access to a real Tastytrade options account, live option-chain and Greeks data over DXLink, multi-source OHLCV research data with automatic fallback, and a deliberately narrow, dry-run-gated path to actually placing trades. 39 tools, one principal, zero standing write access.
What It Is
Most "AI trading bot" demos either can't actually touch a broker account or touch it with no guardrails at all. This project is built around the opposite bet: give an AI client (Claude, or any MCP-speaking client) full read access to a real Tastytrade account and live market data, but make every single write operation — placing or replacing an order — impossible to reach without a broker-confirmed dry run and a one-use, time-boxed token. The AI can see everything and propose anything; it can only act through a narrow, auditable gate.
The server exposes 39 tools over MCP's Streamable HTTP transport at /mcp. Read tools cover Tastytrade accounts, balances, positions, transactions, orders and option chains; live market data comes from Tastytrade's DXLink WebSocket feed (quotes, Greeks, VIX, option-premium candles); historical OHLCV research data is sourced from Alpaca first, with Alpha Vantage and a last-resort Yahoo Finance fallback, each explicitly flagged in the response so a client always knows provenance. A separate chart pipeline (normalize → calculate indicators → render SVG) turns any of those candle sets into a self-contained technical chart. Order placement exists in two forms — a certification-host sandbox and a real, guarded live path — both built on the same dry-run-then-token pattern, both disabled by default, both capped on contract size.
In production it runs on Azure App Service behind Microsoft Entra ID: every MCP session is bound to one specific, allowlisted Microsoft account, and trading tools require a separate OAuth scope from read tools. Broker credentials never leave the server, never reach the MCP client, and are stored as Key Vault references rather than plaintext App Service settings.
Tool Inventory (39 tools, 7 providers)
| Provider / domain | Representative tools | What it's for |
|---|---|---|
| Tastytrade (read) | tastytrade_list_accounts, _get_balance, _list_positions, _get_option_chain, _get_trading_status | Real account state — never cached beyond process memory, never written to disk |
| DXLink (live data) | dxlink_get_option_quote, _get_option_greeks, _get_option_snapshot, _get_option_candles | Live bid/ask/last, IV and Greeks, and historical-to-live option premium candles over a pooled WebSocket |
| Tastytrade sandbox | tastytrade_sandbox_dry_run_option_order, _submit_option_order, _cancel_order | Certification-host order testing — cannot reach the production broker host at all |
| Tastytrade live | tastytrade_live_dry_run_option_order, _submit_option_order, _replace_order, _cancel_order | Real orders — disabled by default, guarded single-leg only, requires mcp.trade scope |
| Alpaca | alpaca_get_research_bars | Primary OHLCV source — cleaned, bad-print-filtered, cached multi-timeframe SIP bars |
| Alpha Vantage | alpha_vantage_get_candles | Backup OHLCV source when Alpaca data is unavailable or insufficient |
| Yahoo Finance | yahoo_get_candles | Last-resort delayed fallback — explicitly flagged as such in the response |
| Charting | chart_normalize_candles, _calculate_indicators, _render_svg | Bollinger, EMA 8, SMA 20/50/200, RSI 14, Stoch RSI, MACD — rendered to in-memory SVG, nothing hits the filesystem |
| VIX / regime | market_get_vix_snapshot, _get_vix_candles, market_classify_volatility_regime | Volatility context via DXLink, classified against explicit deterministic thresholds |
| Ranking | options_rank_contracts | Ranks supplied candidate contracts by expiry, spread, liquidity and premium risk — deterministic, not a model call |
| Visual capture | tradingview_capture_chart | Puppeteer-core + headless Chromium screenshots the public 6-timeframe TradingView dashboard |
| Delivery | gmail_send_email | Confirmed, scope-limited (gmail.send only) email delivery with attachments |
| Operability | system_get_diagnostics | Recent tool timings, auth/network wait, failure-stage labels — no symbols, args or secrets logged |
Tech Stack
type: module)Architecture & Design Decisions
Dry-run, then a one-use confirmation token — the core safety pattern
Every write path — sandbox and live, submit and replace — follows the same two-call shape. The first call is a broker-side dry run: Tastytrade actually validates the order (buying power, margin, naked-short checks) and the server returns that preview plus a short-lived, payload-bound confirmation token. The second call must present the exact account and that exact token. The token expires after two minutes and is single-use, so there's no way for a client to "submit" something it never previewed, and no way to replay an old confirmation against a changed order.
Live trading is structurally narrower than sandbox
Both order paths only ever permit single-leg equity-option Buy to Open limit entries and Sell to Close limit/stop/stop-limit exits. Naked shorts and free-form multi-leg payloads are rejected outright — there's no tool that accepts an arbitrary order JSON. Live trading is additionally disabled by a dedicated flag (TASTYTRADE_LIVE_TRADING_ENABLED) and capped at a configurable max contract count, and — unlike sandbox, which API-key auth can unlock — live submission is only reachable with Entra auth carrying the separate mcp.trade scope. Read-only account tools work under either auth mode; nothing about placing a real order is reachable from the simpler local dev auth path.
Three-tier OHLCV fallback with explicit provenance, not silent substitution
Alpaca SIP bars are the primary research source. When Alpaca is unavailable or insufficient, Alpha Vantage is the backup. Yahoo Finance exists purely as a last resort and the tool documentation is explicit that its use should be flagged, not treated as equivalent data. This was a deliberate choice over picking "whichever API responds first" — a chart built on data from a worse source needs to say so, because a trading decision made on stale or less-reliable data is a different trading decision.
Bad-print filtering with an audit trail, not silent cleanup
The Alpaca client deliberately requests data ending 20 minutes behind the current time and caches each base dataset for 15 minutes, so a displayed chart is normally 20-35 minutes delayed. Before aggregating bars it rejects impossible OHLC ranges and isolated price spikes where the neighbouring candles agree the spike doesn't belong — but every response includes a filteredBadPrints count per symbol, so a client can see exactly when and how much cleanup happened. The filter is tuned to remove obvious bad ticks, not to paper over a real gap or a genuine sustained move.
DXLink: one pooled, reused WebSocket instead of a connection per request
The 24-hour DXLink bearer token is fetched and consumed internally — it's never returned to the MCP client, even from the tool whose entire job is reporting connection status. The server caches it for up to 23 hours with a one-hour safety margin and coalesces concurrent refresh attempts into one. Snapshot and candle requests share reusable connection pools per socket type; requests within a pool are serialized (DXLink's reset operation changes connection-wide feed state) while snapshot and candle-history work can run concurrently. Identical snapshot requests are cached 2 seconds, live-market composites 3 seconds, candles 15 seconds, with in-flight duplicate requests coalesced rather than re-issued. Idle sockets close after 15 minutes and are transparently recreated on the next call.
Composable tools over one do-everything tool
There is deliberately no single "build me a trade recommendation" tool. Fetching VIX context, reading an option chain, ranking candidates and charting are all separate tools that a client composes in whatever order the situation calls for. options_rank_contracts itself is pure, deterministic logic over supplied candidates (expiry, spread, liquidity, premium risk) — not a model call — so ranking is auditable and reproducible rather than a black box the AI could silently vary.
Structured, secret-free diagnostics instead of request logging
Every tool call emits a structured JSON start/completion log carrying only the tool name, request ID, duration and outcome, with one correlation ID following a call into Tastytrade. Per-provider diagnostics record token source (cache / refresh / shared-refresh), auth wait, network wait, parse time and failure stage; DXLink diagnostics separately track pool reuse, queue wait, first-event latency and event count. Symbols, query arguments, responses, account data, tokens and secrets are never written to these logs — system_get_diagnostics surfaces operability signal without ever becoming an audit log of what was traded.
structuredContent over duplicated text for large payloads
Machine-readable MCP results live in structuredContent; when a tool is called with response_format=json, the accompanying text block is intentionally just a compact summary rather than a second copy of the full JSON. For a nested option chain or a multi-symbol candle set this measurably cuts serialization cost, transport size and the tokens a model has to read. Option chains are additionally cached by normalized underlying symbol for 60 seconds with identical in-flight requests coalesced.
Process-local state means cold starts are a known, measured cost
The Tastytrade REST token, the DXLink quote token and socket, and the dashboard snapshot cache all live in process memory, not a shared store. Scaling or restarting the App Service creates cold instances whose first request re-authenticates and reconnects from scratch. Rather than hide this, system_get_diagnostics exposes provider-level timings separately from App Service behaviour, so a cold-start latency spike is diagnosable as "new instance" rather than "broker is slow" — and the README is explicit that Always On, a warm health check, and a minimum instance count are what actually fix it in production.
Three read-only dashboards, deliberately kept separate from the MCP surface
A public TradingView-embed dashboard (six linked timeframes, no credentials), a password-protected DXLink live dashboard (read-only quote/trade/5-min chart over the same shared socket the MCP tools use), and a private Alpaca-based research dashboard (Monthly/Weekly/Daily/4H/1H SVG charts with the same indicator set as the MCP chart tools) all exist as plain Express routes, not MCP tools. They give a human a fast visual sanity check without expanding what an AI client can reach.
Authentication, Deep Dive
Two authentication modes, switched by one environment variable, each with a completely different trust model. This is the actual implementation, not a paraphrase of it:
AUTH_MODE=api_key — local development only
The bearer token is compared against MCP_SECRET using Node's timingSafeEqual — a fixed-length, constant-time comparison specifically chosen so a timing side-channel can't be used to guess the secret byte by byte. This mode can reach every read tool and the sandbox order tools (behind their own enable flag), but it structurally cannot carry the mcp.trade scope, so it can never place a live order no matter what else is configured.
AUTH_MODE=entra — the required Azure production mode
Incoming bearer tokens are verified with jose's jwtVerify against a remote JWKS set (Microsoft's live signing keys, fetched and cached, not hardcoded), checking RS256 signature, issuer and audience in one call. Past that, the code independently checks three more things the signature alone doesn't guarantee: the token's tenant ID (tid) matches the configured single tenant exactly; the token's object ID (oid) — not username, not email, the immutable Entra object identifier — is on an explicit allowlist of one or more Object IDs; and the token's scope claim (scp) contains at least mcp.read, with live write tools separately re-checking for mcp.trade. A valid Microsoft token for the wrong tenant, or a valid token for the right tenant but an unlisted user, is rejected identically to an invalid signature.
Standards-based discovery, not a custom login flow
The server publishes MCP protected-resource metadata at /.well-known/oauth-protected-resource, and an unauthenticated request to /mcp gets a 401 with a WWW-Authenticate header pointing at it — the standard OAuth protected-resource discovery pattern, so any MCP client (Claude, Codex, others) can complete the Entra OAuth dance itself rather than needing bespoke integration code. App Service's own authentication redirect is deliberately left off /mcp, because a login-page redirect would break that discovery contract; the Node service does its own token validation instead.
Secrets never touch the client, source control, or plaintext App Service settings
App Service runs under a system-assigned managed identity granted read access to the required Key Vault secrets. The Tastytrade client secret and refresh token are configured as Key Vault references, not literal values, in both the sandbox and production/live environment blocks. Access tokens for Tastytrade live in process memory for their broker-issued lifetime only; the DXLink quote token is consumed internally and is never returned by any tool, including the one whose entire purpose is confirming DXLink connectivity.
Interview Talking Points
mcp.read or mcp.trade scope depending on what the tool needs. It's single-tenant, single-user by design — this isn't multi-tenant SaaS, it's one account's own automation, so the allowlist is intentionally as narrow as it can be.filteredBadPrints count so the cleanup itself is auditable rather than invisible.main runs GitHub Actions: install, run the syntax-check script across every source file, run the full test suite, then deploy the tested runtime to the options-trading-mcp-sean Azure App Service (Linux, Node 22, Always On). There's also a Dockerfile for container-based deployment. /health and the OAuth protected-resource metadata endpoint stay publicly reachable for monitoring and client discovery; /mcp itself stays behind the JWT middleware the whole time.