// Node.js MCP server · Azure App Service · Entra ID OAuth · Multi-broker

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.

Node.js 22 / Express 5 @modelcontextprotocol/sdk Microsoft Entra ID (OAuth 2.0) Tastytrade + DXLink Alpaca · Alpha Vantage · Yahoo Azure App Service + Key Vault Puppeteer-core + @sparticuz/chromium

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 / domainRepresentative toolsWhat it's for
Tastytrade (read)tastytrade_list_accounts, _get_balance, _list_positions, _get_option_chain, _get_trading_statusReal 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_candlesLive bid/ask/last, IV and Greeks, and historical-to-live option premium candles over a pooled WebSocket
Tastytrade sandboxtastytrade_sandbox_dry_run_option_order, _submit_option_order, _cancel_orderCertification-host order testing — cannot reach the production broker host at all
Tastytrade livetastytrade_live_dry_run_option_order, _submit_option_order, _replace_order, _cancel_orderReal orders — disabled by default, guarded single-leg only, requires mcp.trade scope
Alpacaalpaca_get_research_barsPrimary OHLCV source — cleaned, bad-print-filtered, cached multi-timeframe SIP bars
Alpha Vantagealpha_vantage_get_candlesBackup OHLCV source when Alpaca data is unavailable or insufficient
Yahoo Financeyahoo_get_candlesLast-resort delayed fallback — explicitly flagged as such in the response
Chartingchart_normalize_candles, _calculate_indicators, _render_svgBollinger, EMA 8, SMA 20/50/200, RSI 14, Stoch RSI, MACD — rendered to in-memory SVG, nothing hits the filesystem
VIX / regimemarket_get_vix_snapshot, _get_vix_candles, market_classify_volatility_regimeVolatility context via DXLink, classified against explicit deterministic thresholds
Rankingoptions_rank_contractsRanks supplied candidate contracts by expiry, spread, liquidity and premium risk — deterministic, not a model call
Visual capturetradingview_capture_chartPuppeteer-core + headless Chromium screenshots the public 6-timeframe TradingView dashboard
Deliverygmail_send_emailConfirmed, scope-limited (gmail.send only) email delivery with attachments
Operabilitysystem_get_diagnosticsRecent tool timings, auth/network wait, failure-stage labels — no symbols, args or secrets logged

Tech Stack

Server runtime
Node.js 22ESM (type: module)
Express 5HTTP transport, dashboard routes
@modelcontextprotocol/sdkMCP server + Streamable HTTP
zodTool input schema validation
dotenvLocal env loading only
Auth
joseJWKS fetch + RS256 verification
node:cryptotimingSafeEqual for API-key mode
Microsoft Entra IDSingle-tenant app registration, delegated scopes
Market data clients
tastytrade-client.jsREST + token lifecycle
dxlink-client.jsPooled WebSocket, request coalescing
alpaca-market-client.jsPrimary OHLCV, bad-print filter
alpha-vantage-client.jsBackup OHLCV
yahoo-finance-client.jsLast-resort, flagged fallback
Visual / rendering
chart-tools.jsIn-memory SVG indicator charts
puppeteer-coreHeadless capture, no bundled Chromium
@sparticuz/chromiumServerless-sized Chromium binary
Azure infrastructure
App ServiceLinux, Node 22, Always On
Key VaultBroker client secret + refresh token references
Managed IdentityApp Service → Key Vault, no stored vault credentials
GitHub ActionsTest + deploy on push to main

Architecture & Design Decisions

01

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.

dry_run_option_order→broker preview + confirmation token (120s TTL)→submit_option_order(token)
02

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.

03

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.

04

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.

05

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.

06

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.

07

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.

08

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.

09

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.

10

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:

// src/auth.js — the real dispatch async function authenticate(request) { const token = bearerToken(request.headers.authorization); if (!token) throw new McpAuthError("Authentication is required."); if (config.mode === "api_key") return authenticateApiKey(token, config); return authenticateEntra(token, config, verify, jwks); }
A

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.

B

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.

C

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.

D

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

Q
Why did you build this?
I trade options myself using a mean-reversion system, and I wanted an AI client to be able to genuinely help with research — reading live Greeks, pulling multi-source history, ranking contracts — without being one bad instruction away from touching real money carelessly. Most "AI + trading" examples either stay read-only forever or hand over a raw order-placement API. I wanted to build the actual hard part: a write path that's real but can't be misused.
Q
Walk me through how authentication works.
One dispatch function picks between two modes. Local development uses a shared secret compared with a constant-time comparison. Production uses Microsoft Entra ID: I verify the JWT's signature against Microsoft's JWKS, then separately check tenant ID, check the token's object ID against an explicit allowlist of exactly one Microsoft account, and check for an 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.
Q
What was the hardest problem you solved?
Making "the AI can place trades" safe enough to actually enable. The answer wasn't a smarter prompt — it was structural: every write is a dry-run against the real broker followed by a one-use, two-minute, payload-bound confirmation token, single-leg only, naked-shorts blocked, contract-count capped, and gated behind a separate OAuth scope that the simpler auth mode can never obtain. The AI proposes; it cannot act without a broker-confirmed preview in hand.
Q
How does the market-data fallback actually work?
Alpaca SIP bars are primary. If that source is unavailable or thin, Alpha Vantage is the backup. Yahoo Finance is last-resort only, and every tool response flags which source actually served the data — so a client never silently gets worse data without knowing it. Alpaca's own client also runs a conservative bad-print filter before returning bars, and reports a filteredBadPrints count so the cleanup itself is auditable rather than invisible.
Q
Why DXLink instead of just polling Tastytrade's REST API for quotes?
REST polling for live option quotes and Greeks would mean constant round-trips and stale-by-definition data. DXLink is Tastytrade's streaming quote feed — one authenticated WebSocket, pooled and reused across requests, with short-lived response caching (2-15 seconds depending on the call) and request coalescing so concurrent identical asks don't each open new work. The 24-hour bearer token for it is fetched and refreshed internally and never leaves the server, including from the one tool whose entire job is reporting that the connection works.
Q
Why Node and Express instead of Python for a trading/data-heavy server?
The official MCP SDK's reference implementation and the Streamable HTTP transport are Node-first, and the workload here is I/O-bound — waiting on broker REST calls, DXLink WebSocket round-trips, and Alpaca/Alpha Vantage HTTP calls — which is exactly what Node's event loop is good at. The one CPU-ish piece, technical indicator calculation and SVG rendering, is cheap enough per request that it never needed a separate worker process.
Q
What would you add next?
Multi-leg order support (verticals, iron condors) with the same dry-run/token guard extended to a whole leg set rather than one leg. I'd also want to move the process-local DXLink socket and token cache to something shared (Redis) so a scaled-out App Service doesn't cold-start every instance's connection independently — right now that's a known, measured trade-off rather than a hidden one.
Q
What did you learn building this?
That "safe" has to be a property of the code path, not of the prompt. Early on I considered just instructing the AI client never to submit an order without asking me first — but an instruction is advice, and a token that expires in two minutes and is bound to one previewed payload is a constraint. The second one is the only one I'd actually trust with a funded account.
Q
How is this deployed and kept running?
A push to 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.