Putting a live options feed behind MCP
· mcp, llm, tastytrade, dxlink, typescript, options
Nothing here is financial advice, and the software I'm describing doesn't give any either. That turns out to be the whole design problem.
The thing I wanted
I wanted to open a chat window and ask "what cash-secured puts should I be looking at right now?" and get back an answer built out of live quotes from my actual brokerage--not a plausible table of strikes the model made up because option chains are extremely easy to make up.
Model Context Protocol is the obvious plumbing for that. Register some tools, point Claude or ChatGPT at an endpoint, done. And the MCP layer genuinely was the easy part--a few hundred lines, one afternoon.
The hard part isn't MCP. The hard part is that a language model asked about money will happily produce a confident, well-formatted, completely fictional answer, and the only defense is building a data path where every number it says came from somewhere you can point at.
Options data is not REST-shaped
First surprise, and it reframed the whole project: the number I need most doesn't exist over REST.
tastytrade's REST API is good. OAuth2 with a personal application, a read scope, refresh tokens
exchanged for ~15-minute access tokens. GET /option-chains/{symbol}/nested gives you the entire
chain--every expiration, every strike, the OCC symbols, and critically the streamer symbols for
each contract.
What it does not give you is delta.
For screening cash-secured puts, delta is the whole ballgame. It's the primary filter and a rough stand-in for assignment probability. And it only arrives over the streaming feed. So:
tastytrade REST
├─ OAuth token POST /oauth/token
├─ nested option chain GET /option-chains/{symbol}/nested
├─ underlying quote seed GET /market-data/by-type?equity=SPY
└─ quote token + feed URL GET /api-quote-tokens
│
▼
DXLink WebSocket
Quote + Greeks + Summary + Trade
│
▼
in-memory snapshot store
REST tells you what exists. DXLink tells you what it's worth. You need both, and the second one is a stateful WebSocket, which is where the architecture starts making decisions for you.
The DXLink handshake
GET /api-quote-tokens returns a short-lived token and a dxlink-url. From there it's a fixed
five-step dance before a single quote arrives:
SETUP (channel 0) → AUTH_STATE: UNAUTHORIZED
AUTH (channel 0) → AUTH_STATE: AUTHORIZED
CHANNEL_REQUEST (channel 3, service FEED, contract AUTO)
→ CHANNEL_OPENED
FEED_SETUP (channel 3) → FEED_CONFIG
FEED_SUBSCRIPTION (channel 3)
Plus a KEEPALIVE on channel 0 every 30 seconds forever, or the server hangs up on you.
I wrote this against the raw protocol with Node 22's global WebSocket instead of pulling in the
official SDK. Not because the SDK is bad--it isn't--but because when the feed goes quiet at 2am
I want to read the actual frames, not guess which abstraction swallowed them.
The gotcha is FEED_SETUP. I ask for the compact data format:
{
type: "FEED_SETUP",
channel: 3,
acceptAggregationPeriod: 0.1,
acceptDataFormat: "COMPACT",
acceptEventFields: {
Quote: ["eventType", "eventSymbol", "bidPrice", "askPrice", "bidSize", "askSize"],
Greeks: ["eventType", "eventSymbol", "volatility", "delta", "gamma", "theta", "rho", "vega", "price"],
Summary:["eventType", "eventSymbol", "openInterest", "dayOpenPrice", "dayHighPrice", "dayLowPrice", "prevDayClosePrice"],
},
}
Compact means events come back as positional arrays with no keys, batched by type:
["Quote", [ "Quote", ".SPY260918P490", 4.15, 4.25, 12, 30,
"Quote", ".SPY260918P485", 3.40, 3.55, 8, 41, ... ],
"Greeks", [ ... ]]
There is no self-describing structure in that payload. The only thing that turns it back into
objects is the acceptEventFields map you sent during setup. Get that wrong--or let it drift
from what you're parsing--and you don't get an error. You get bid prices in the delta column and
a screener that produces beautifully formatted nonsense.
The parser is boring on purpose: walk the array in strides of fields.length, zip against the
declared field names, drop anything without a symbol.
The other symbology trap: the nested chain response gives you put-streamer-symbol per strike.
Use it. Do not derive DXLink symbols from OCC symbols yourself. They look regular enough that
you'll be tempted, and then one index or weekly will break the pattern and you'll be silently
subscribed to nothing.
Watching a lot of contracts at once
This is where it got interesting, and where the naive version falls over.
I'm not watching one symbol. I'm watching a basket of underlyings, and for each one I want the
puts that could plausibly qualify. The obvious move is to subscribe to the whole chain and filter
later. That fails immediately: an api-level quote token rejects oversized FEED_SUBSCRIPTION
frames, and one SPY expiration alone is hundreds of strikes.
So there's a subscription budget, and every subscription is a (symbol, eventType) pair. The
current shape:
- 1 expiration per underlying, chosen from the DTE window
- 25 put strikes per underlying
- 4 event types per option (Quote, Greeks, Summary, Trade)
- 2 per underlying (Quote, Summary), plus VIX
That's roughly 700 entries, sent in batches of 100--first batch reset: true, the rest appending.
Well within limits, and it still covers everything that has a real chance of passing the filters.
But narrowing to 25 strikes requires knowing which strikes matter, which requires delta, which only arrives after you subscribe. Chicken, meet egg.
The way out is a two-stage filter where stage one is deliberately dumb:
export function selectInitialPutStrikes<T extends { strike: number }>(
puts: T[],
underlyingPrice: number,
maxCount: number,
): T[] {
const low = underlyingPrice * 0.85;
const high = underlyingPrice;
const band = puts.filter((p) => p.strike >= low && p.strike <= high);
const pool = band.length > 0 ? band : puts.filter((p) => p.strike <= underlyingPrice);
const sorted = [...pool].sort(
(a, b) =>
Math.abs(a.strike - underlyingPrice * 0.92) -
Math.abs(b.strike - underlyingPrice * 0.92),
);
return sorted.slice(0, maxCount).sort((a, b) => b.strike - a.strike);
}
Puts between 85% and 100% of spot, ranked by distance from 92% of spot. That's it. It's a moneyness heuristic standing in for a delta band, and it's wrong at the edges--a high-IV name will put its 0.30-delta put well below 85% of spot. It doesn't have to be right. It has to be a superset that fits in the subscription budget, cheap enough to recompute whenever chains refresh.
Then Greeks arrive over the stream and stage two filters on the real number.
The rule I broke early and had to put back: when delta is missing, the answer is "insufficient data," not an estimate. I could compute a Black-Scholes delta from strike, spot, IV, and DTE. It would look completely legitimate in the output. It would also be a number I invented sitting next to numbers the exchange published, in a response a language model is about to summarize, with no way for the reader to tell them apart. Missing delta is a hard fail now.
Same for staleness. Every snapshot tracks when its quote and its Greeks last updated, and anything
past the freshness window is INSUFFICIENT_DATA--even though it still has perfectly
reasonable-looking prices attached.
Two processes, not one
Here's the architectural decision I'd make again immediately.
The obvious build is one service: MCP tool call comes in, connect to DXLink, subscribe, screen, respond. It cannot work. Cold start is REST auth, chain fetch, WebSocket handshake, subscribe, then wait for Greeks to actually tick in. That's tens of seconds on a good day. An MCP tool call has a patience budget measured in seconds. You'd return empty results and the model would cheerfully narrate them.
So it's two containers:
tastytrade-watcher--the data plane. Runs forever, holds the DXLink session open, reconnects
with exponential backoff, refreshes chains on a timer, deliberately drops the WebSocket every few
hours to re-acquire a quote token before it expires. It keeps a warm in-memory snapshot store and
exposes plain HTTP: POST /v1/tools/find_safeguard_csp and friends. It's not on the public
network.
tastytrade-mcp--the gateway. Streamable HTTP MCP at /mcp, OAuth on the same origin, and
tool handlers that map 1:1 onto the watcher's routes. It holds no market state at all.
By the time a tool call lands, the answer is already in memory. The screening pass is pure computation over snapshots--no I/O, no waiting.
The 1:1 mapping is worth the discipline. Every MCP tool is a curl-able HTTP endpoint, which means
I can reproduce any weird model behavior from a terminal without an LLM in the loop. Debugging
"the tool returned something strange" and debugging "the model said something strange" are very
different jobs, and you want a seam between them.
Quote ticks are never logged, by the way. They'd bury everything. The watcher emits a heartbeat every 60 seconds with connection state, snapshot count, and how many snapshots actually have a delta yet--which is the number that tells you whether your market data entitlements are what you think they are.
Designing tools a model can't launder
The screening endpoint could return a ranked list of contracts. It doesn't, and this is the part I think generalizes past options.
A language model in front of your tool is a summarizer. Whatever nuance you express in prose gets compressed, and compression drops qualifiers. "This passes the delta and credit checks, though we couldn't verify you have the cash" becomes "this one qualifies." That's not the model being bad. That's what summarizing is.
So the nuance has to be structured data with names, not sentences. Every check comes back as a tagged result:
{
id: "min_credit",
kind: "DOCUMENTED_RULE",
status: "provisional",
detail: "PROVISIONAL_CREDIT_PASS: gross bid credit $112.00 >= $100. " +
"Final compliance requires confirming at least $100 after commissions and fees."
}
Two fields do the heavy lifting.
kind separates DOCUMENTED_RULE--an actual rule of the strategy--from
APPLICATION_FILTER and scanner preferences, which are my screener's opinions. The DTE
window is a preference. The ranking order is a preference. The delta band is a rule. Without that
distinction the model can present my sorting choices as strategy doctrine, and every response
carries an explicit preferenceNotes array saying which is which.
status is deliberately not a boolean. It's pass / fail / warning / provisional /
unknown / unevaluated, and those last three carry most of the weight:
unknown--I could evaluate this, you didn't give me the input. Do you have $70,000 in cash? I have areadscope on market data. I have no idea.unevaluated--this rule exists and I structurally cannot check it. Earnings dates. 52-week context. Portfolio-level exposure.provisional--passes on the data I have, would need something I don't have to be final. Credit clears $100 on the gross bid, but I'm not modeling your commissions.
Those roll up into one label per candidate: DT_COMPLIANT_CANDIDATE, PROVISIONALLY_COMPLIANT,
NOT_DT_COMPLIANT, INSUFFICIENT_DATA, or OUTSIDE_STANDARD_SAFEGUARD_SCOPE. A contract only
reaches the top label if every documented rule is a clean pass. Any unknown or provisional
anywhere and the best it gets is "provisionally compliant."
The screen also returns near-misses and a sample of rejects on purpose. A tool that only shows winners teaches the model that winners are all there is, and "here are 3 candidates" reads very differently from "here are 3 candidates out of 175 watched, and here's what the other 172 failed on."
And every response ships an unevaluatedRules list, a humanChecklist, marketDataAsOf, and
marketsOpen. The last one matters more than it sounds--a screen at 3am returns real snapshots of
a market that closed eleven hours ago, and nothing in the numbers themselves says so.
Willingness to own is not a data problem
The rule that clarified the whole design: a cash-secured put obligates you to buy 100 shares at the strike. The only real question is whether you actually want to own that stock at that price.
No amount of market data answers it. So the tool takes willingToOwn as a caller-supplied boolean,
and if it's absent the rule is unknown and the candidate is capped at provisional. Same for
availableCash--without it, "is this actually cash-secured?" is unknown, and every candidate
carries TRADER MUST BE WILLING TO OWN 100 SHARES AT THIS PRICE in its warnings.
The tool cannot certify a trade, and it's structurally incapable of pretending otherwise. That's a feature I'd want in any tool where being confidently wrong has a cost.
Read-only, all the way down
Layers, from the outside in:
The OAuth grant at tastytrade has read scope only. No trade scope was ever requested. If
every other layer failed simultaneously, the credentials cannot place an order.
Every MCP tool is annotated:
annotations: { readOnlyHint: true, openWorldHint: false }
There is no order-submission tool, no dry-run order tool, no "prepare an order for confirmation" tool. Not deferred--absent. The interesting boundary for an LLM plus a brokerage isn't how you confirm writes. It's whether writes exist in the tool surface at all, and for v1 the answer is easy.
Access is an email allowlist plus magic invite links I hand out privately. No passwords, no mail provider. It doesn't cryptographically prove mailbox ownership; it binds a connector to an identity I agreed on with a human over a channel I trust. For a handful of people, that's the right amount of auth, and I'd rather ship something honest about its threat model than bolt on a login system whose guarantees I'd have to explain away anyway.
Audit the arguments, not just the answers
Every tool call, resource read, and prompt fetch writes full JSON to disk:
data/audit/{date}/{sessionId}/{timestamp}_{requestId}_{tool}.json
Complete arguments, complete result, caller email, duration. Stdout gets a slim line with a pointer to the file, so log shipping stays cheap.
This has been the single most useful thing I built, and it's the least clever.
When someone says the assistant told them something odd, the question is never really "what did the
model say." It's what did the model have. Which arguments did it invent versus pass through? Did
it call find_safeguard_csp with willingToOwn: true without ever asking? Did it read the
compliance label and then describe a PROVISIONALLY_COMPLIANT candidate as a good trade?
The audit viewer is session-grouped so you can walk a conversation step by step. Every one of those questions is answerable in about thirty seconds, which is roughly thirty seconds faster than any amount of reasoning about what the model was probably thinking.
One quirk worth knowing: a single chat can open more than one MCP sessionId, so the session with
your tool calls isn't always the first one in the list.
What I'd tell you if you're about to build one
- Find the number that doesn't come over REST. It'll dictate your architecture more than the protocol does.
- Two processes. Whatever is slow and stateful runs forever and stays warm. The MCP server is a thin, stateless mapper. Cold-starting real work inside a tool call doesn't fit the latency budget.
- Keep an HTTP seam under MCP. Being able to
curlany tool without a model is worth the duplicated route definitions many times over. - Encode uncertainty as enum values, not prose.
unknown,provisional, andunevaluatedsurvive summarization. A caveat in a sentence does not. - Label your opinions. Mark the difference between the domain's rules and your app's preferences, in the payload, or the model will present your ranking function as expertise.
- Never synthesize a missing input. An estimate that looks like a measurement is the single most dangerous thing you can hand a summarizer.
- Audit inputs, not just outputs. You will need to know what the model had, not what it said.
- Scope down at the credential.
readOnlyHintis a hint. A read-only OAuth grant is a fact.
The screener still doesn't know about earnings dates, 52-week context, or my actual portfolio
exposure--all of which it says out loud, every time, in unevaluatedRules. That's the version I
trust: not the one that knows the most, the one that's specific about what it doesn't.