Skip to main content

Command Palette

Search for a command to run...

Cutting MCP Round-Trips with Jev (Part 3)

Updated
11 min readView as Markdown

The ping-pong problem MCP pays every day

MarketDX lets AI assistants (like Claude) pull market data through MCP — a set of "tools" the LLM can call itself: find stocks, pull financials, run screens, and so on.

Sounds simple, but one step is quietly expensive: turning a human phrase into the values the system actually understands.

Take a real question:

"Retail stocks whose current assets are accelerating sharply"

Before it can screen anything, the system has to convert three pieces into tokens the database knows:

  • "current assets" → CurrentAssets
  • "accelerating sharply" → accelerating
  • "retail" → 2550 (a GICS code)

We do this with a family of find_* tools (find_fsconcept, find_fspattern, find_gics, …). The trouble is how they work:

  1. The LLM calls find_fsconcept("current assets") → the system returns a list of candidates (top-k), maybe 10 of them
  2. The LLM reads them and picks the right one
  3. Repeat with find_fspattern → top-k → pick
  4. Repeat with find_gics → top-k → pick
  5. Only then call the real screening tool with the chosen values

Count the round-trips: four back-and-forths before the real work even starts. Each one has network latency, and — worse — the LLM has to burn reasoning tokens reading the top-k and deciding every single time.

That's the "ping-pong" MCP hits on nearly every query. The more complex the question (several concepts), the more rounds, and the longer the user waits.

How find_* works inside (vector search 101)

First, how does find_* turn "current assets" into CurrentAssets? This is the heart of the story.

The key technique is a vector embedding — it turns the meaning of text into a long list of numbers (a vector with thousands of dimensions). Text with similar meaning ends up with vectors that sit close together in that space.

We do two things:

  1. Prepare once, ahead of time: every concept in the catalog ("Total Revenue", "Current Assets", "Gross Margin", …) is turned into a vector by an embedding model (we use Gemini's) and stored.
  2. At query time: take the user's phrase ("current assets"), embed it the same way, and find its nearest neighbours (by cosine similarity) — closer vector = closer meaning.

Sort by closeness, take the top k = the top-k. (There's also a first pass that matches exact words/aliases — lexical match — before the semantic step, but the idea is the same: "return the ~10 most similar candidates.")

Here's the weakness: "close in vector space" doesn't always mean "correct." Near-meaning terms — "return on equity" vs "dividend per share", "allowance for doubtful accounts" vs "provision for doubtful accounts" — have vectors so close they get mixed together in the top-k, and sometimes the wrong one even ranks higher than the right one. That's exactly why the system doesn't dare commit to a single answer.

Why return top-k at all?

Good question — why doesn't find_* just return one answer and be done?

Because rank 1 isn't always right. We measured it on find_fspattern (which matches financial-statement "patterns"): blindly trusting the vector's #1 is correct only 71% of the time — but the right answer is somewhere in the top-10 100% of the time.

find_fspattern (51 patterns):
  trust rank 1 blindly     → 71% correct
  answer is in top-10      → 100%

See that 71% → 100% gap? That's why top-k exists — the system isn't confident enough to commit, so it hands the choices back to the LLM. And that handoff is the ping-pong.


The idea: move "the choosing" to the server

If the problem is "the system won't choose, so it punts to the LLM" — what if we had something that dares to choose, on the server side?

That's where Jev comes in. From Parts 1–2 we know it's a fast, cheap "decision model": give it options and a question, and it returns one answer plus a confidence score — no long text to generate.

So the new shape is:

Before:  user → LLM picks route → find_* returns top-k → LLM picks → call real tool   (ping-pong)
After:   user → LLM sends raw phrase into the tool → tool lets Jev choose → carry on   (one round)

The LLM sends "current assets" straight into the screening tool; the tool calls Jev internally to turn it into CurrentAssets, then screens — cutting out the whole "return top-k, let the LLM pick" detour.

The question is whether Jev chooses accurately enough to do this. We tried two scenarios.


Small catalog: let Jev pick from the whole set at once

find_fspattern has 51 patterns total — small enough to hand the entire set to Jev as options and let it choose in one shot.

Results:

find_fspattern (51 patterns, Thai & English queries):
  Jev picks once            → 92.9% correct
  vector rank 1 (old way)   → 71.4% correct

Jev leaves the old rank-1 in the dust and gets close to the 100% ceiling that normally requires the LLM to re-pick — but in a single shot, no ping-pong.

Surprise: most of the "misses" were our fault

At first we had a case that looked like a Jev miss: the query "the stock's value doubled" — Jev answered "higher than the rest" instead of "compound growth."

We nearly blamed Jev — until we looked at what we'd fed it. We were only sending each pattern's short definition, and the compound-growth definition talked only about "annual rate (CAGR)" — the word "doubled" never appeared. Yet the real database had another description field spelling out "the value doubles or triples."

The fix: feed the full description that the search itself actually uses — and Jev got it right immediately.

This echoes what we learned in Part 2: before blaming the model, look at what you fed it. Almost every "miss" turned out to be context we didn't fully supply, or a reference answer of ours that was too narrow.


The main case: too many candidates to fit — vector + Jev

This is the common, important one: most catalogs are too big to hand to Jev whole. The financial-concept finder (find_fsconcept) has 428 entries (statement lines + ratios + valuation multiples); the stock finder has tens of thousands; the economic-data finder tens of thousands more.

Dumping all 428 into Jev at once won't work (expensive and over the limit). But we don't have to throw away what we have — let the existing vector search pre-filter first:

raw phrase → [vector search narrows to top-15] → [Jev picks 1 of 15] → token

Vector search does what it's good at (narrow thousands to ten); Jev does what it's good at (pick the right one out of ten) — and the choosing still happens on the server, no ping-pong back to the LLM.

Results:

find_fsconcept (428 entries, English queries):
  vector rank 1 (old way)      → 68% correct
  vector top-15 → Jev picks    → 100% correct

Jev fixed every case the vector mis-ranked — genuinely hard ones, like:

  • "ผลตอบแทนผู้ถือหุ้น" or "return on equity (ROE)" in English — vector guessed "dividend per share" → Jev picked roe
  • "หนี้ที่มีภาระดอกเบี้ยรวม" or "total interest-bearing debt" in English — vector guessed "accrued interest" → Jev picked total_debt
  • "งานระหว่างก่อสร้าง", "เงินลงทุนที่ถือจนครบกำหนด", "กำไรขาดทุนเบ็ดเสร็จอื่นสะสม" or "construction in progress", "held-to-maturity investments", "accumulated other comprehensive income" — deep accounting terms the vector drifted on, but Jev picked correctly

Even more impressive, it got the type right: "gross profit" → the statement line (gross_profit), while "gross margin" → the ratio (gross_margin). Different things — Jev doesn't confuse them.


And the cost? — vector + Jev is still cheaper

You might assume adding Jev makes it more expensive. The opposite is true.

Approach Options Jev reads Cost / 1,000 calls
Whole catalog (51) 51 $0.35
vector filter → Jev (10–15) ~10 $0.08

Pre-filtering with vectors is ~4× cheaper, because Jev's cost is driven by the length of the options you feed it — reading 10 options is far cheaper than reading 400.

On top of that, the vector's embedding cost is nearly zero, because we store the vectors of previously-seen queries permanently (repeat query = no recompute). And Jev itself bills for input only, output is free, and it's several times cheaper than a general LLM.

In round-trips: from 4 rounds → 1. The user gets an answer noticeably faster, and the client-side LLM doesn't have to break focus to sift top-k on every question — it can spend those tokens on what matters. That's a genuinely better MCP experience.


Lessons worth keeping (the real ones)

Shipping this for real means being honest about the limits. We found four.

1. The context you feed matters more than you think. Almost every Jev "miss" was us feeding an incomplete description, or a reference answer that was too narrow. Fixing the descriptions in the database improves the whole system — vector and Jev both consume the same descriptions.

2. "Asking to confirm" has to be smart, or it destroys the UX you just fixed. Sometimes Jev genuinely isn't sure — the question is ambiguous, or two options are neck-and-neck. We designed the tool to look at the shape of the confidence, not just one number:

  • clearly confident → use it
  • borderline but usable → answer anyway, with a note on how it interpreted the phrase (don't block)
  • two options genuinely close and genuinely different → then ask the user to confirm (rare)

The principle: only ask to confirm when "the right answer exists but Jev can't pick it cleanly" — not every time it's unsure. Otherwise you're back to ping-pong.

3. Multiple languages → translate to English first (and this is a vector limitation, not Jev's). Testing deep accounting terms across languages, we found the Thai word for goodwill (ค่าความนิยม) fell to rank 45 in the vector search — out of the top-15, so Jev never saw it as an option. On the surface it looks like Jev failed.

But we proved it's a cross-language weakness of the embedding, not Jev — ask the same term in English ("goodwill") and it ranks #1 instantly; Jev even knows that のれん (Japanese) means goodwill. Luckily our MCP tools already require the client to translate to English before sending — so this doesn't bite in practice.

4. Razor-thin accounting distinctions — Jev sails through. We built the meanest test we could: pairs of terms that differ by a hair but mean different things.

Distinction to make Jev got it right?
treasury stock ↔ buybacks
provision (P&L) ↔ allowance (balance sheet)
deferred tax asset ↔ deferred tax liability
current ↔ non-current
interest-bearing debt ↔ total liabilities ↔ borrowings (excl. leases)

25 brutal pairs — Jev scored 100% at an average confidence of 0.998, versus the vector's rank-1 at 48%. This is the scariest failure mode (being confidently wrong), and Jev passed it comfortably in English.


Conclusion: when to collapse a resolver with Jev

From what we've tried, the playbook is clear:

  • Small catalog (a few dozen): let Jev pick from the whole set in one shot.
  • Large catalog (hundreds / thousands / tens of thousands): use vector search to pre-filter to ~10–15, then let Jev pick — you still collapse the round-trips and pay less.
  • Add a cache shortcut: phrases you've resolved before (e.g., "current assets" → CurrentAssets) get stored, so next time you return instantly without calling Jev again.

All of this turns MCP from "four rounds of ping-pong per question" into "one round" — faster, cheaper, and lighter on the client LLM.

A caveat: don't flip everything in one day. The safe path is to run Jev in parallel with the old resolver on real traffic for a while, compare the results, watch the confidence distribution, then enable it piece by piece — and keep a fallback in case Jev is slow or down (we measure ~1 second normally, but with a tail that can spike into the tens of seconds).

Still, the direction is clear: a resolver is the "round-trip tax" MCP pays on every query — Jev pays that tax for you. And no matter how many candidates there are, the vector + Jev pairing collapses it, both faster and cheaper.

M

The vector-prefilter-then-Jev split is a clean design and the cost table makes the case well. One thing I'd watch for once this runs in production: you're caching resolved phrase-to-token mappings permanently for cost, but your own first lesson was that most of Jev's misses came down to incomplete catalog descriptions, and fixing those improved the whole system's accuracy. A permanent cache keyed only on the phrase doesn't know the description behind a past resolution has since been corrected, so a wrong answer learned before a description fix can sit in the cache indefinitely and invisibly, while every fresh, uncached query benefits from the fix right away. Keying the cache to a description version, even just a hash of the description text, and invalidating on catalog edits would close that gap without giving up the cost win.

N

Thanks, this is a sharp catch, and you're right. Keying the resolution cache only on the phrase means a description fix silently fails to reach anything already cached, which cuts against the whole point that better catalog descriptions are what moved our accuracy. We'll address this in the next release: hashing the description text (a catalog version) into the cache key and invalidating on catalog edits, so we keep the cost win without letting a pre-fix answer linger. Appreciate you flagging it.

More from this blog

MCP Optimization: ลด round-trip ของ resolver ด้วย Jev (ตอนที่ 3)

ปัญหาที่ MCP เจอทุกวัน: การปิงปอง MarketDX เปิดให้ผู้ช่วย AI (เช่น Claude) เรียกข้อมูลตลาดผ่าน MCP — ชุด "เครื่องมือ" ที่ LLM เรียกใช้ได้เอง ค้นหุ้น ดึงงบ สกรีน ฯลฯ ฟังดูง่าย แต่มีขั้นตอนหนึ่งที่แพงเง

Sep 18, 20266 min read57

เอา Jev มาทำงานจริง — ผล ต้นทุน และบทเรียน (ตอนที่ 2)

งานที่เราเลือกลอง MarketDX แปลงข่าวการเงินเป็นกราฟผลกระทบ หัวใจคือขั้นตอน "อ่านข่าวแล้วจัดประเภท + ประเมิน" ที่ยิง ทุกข่าว ที่ไหลเข้ามา — เป็นงาน LLM ที่ volume สูงสุดในระบบ ถ้าแทนได้คือประหยัดก้อนใหญ

Sep 18, 20264 min read68

MarketDX

6 posts