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.

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 a read scope 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 curl any tool without a model is worth the duplicated route definitions many times over.
  • Encode uncertainty as enum values, not prose. unknown, provisional, and unevaluated survive 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. readOnlyHint is 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.