Skip to content

feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets - #493

Merged
Miracle656 merged 5 commits into
Divinity-Labs:mainfrom
Ugasutun:feat/mcp-lens-get-price
Oct 19, 2026
Merged

Miracle656 merged 5 commits into
Divinity-Labs:mainfrom
Ugasutun:feat/mcp-lens-get-price

Conversation

@Ugasutun

Copy link
Copy Markdown
Contributor

Closes #408

Summary

The MCP server exposed only bazaar_search and paid_call, so an agent
connected over MCP could not reach any of Lens's own aggregated pricing (SDEX,
AMM, Soroswap and Aquarius, combined into a VWAP with a best route and
confidence). This PR adds a lens_get_price tool that returns the aggregated
price for a pair as structured content.

Changes

  • Added the lens_get_price MCP tool, registered alongside the existing tools.
  • The handler calls the same price service the REST route uses. No pricing or
    query logic is reimplemented in the MCP layer.
  • Result is returned as structured content (and mirrored as a JSON text block
    for clients that ignore structured content):
    • price: the VWAP value, as a string to avoid float precision loss
    • sources: which venues contributed (SDEX, AMM, Soroswap, Aquarius)
    • confidence: the aggregated confidence value
    • last_trade_age_seconds: age of the most recent trade used
    • stale: boolean derived from a documented staleness threshold
    • best_route: the best route for the pair
    • base / quote: the resolved assets, each with full issuer
    • network: the network the price was computed on
  • Tool description states what it returns and when to use it ("get the current
    aggregated price for one specific asset pair"), and points to the discovery
    tools (lens_list_pairs) for finding valid assets first.

Asset handling (avoiding #357 and 41b1f62 a third time)

  • base and quote are objects, not strings:
    { code: string, issuer?: string }.
  • issuer is required for every non-native asset. Native XLM is expressed
    explicitly ({ code: "XLM" } / native: true), never by omitting the issuer
    on a credit asset.
  • A bare code for a credit asset is rejected by the inputSchema
    (oneOf native | code+issuer) and again by runtime validation. It is never
    resolved by guessing.
  • Issuers are validated as well-formed Stellar account IDs (G..., 56 chars).
  • Lookup matches on the full (code, issuer) tuple. If the code exists but
    under a different issuer, the tool returns a structured error, not the
    other issuer's price. Results always echo the full issuer back.

Network

  • Optional network argument, enum ["pubnet", "testnet"].
  • Defaults to the active network from server configuration.
  • The resolved network is included in every result so the agent can see what
    it got.

Errors

Structured MCP errors with stable codes so an agent can explain rather than
retry:

  • ASSET_NOT_FOUND: no such asset or pair
  • ISSUER_MISMATCH: code exists but not under the given issuer
  • INVALID_ASSET: malformed code or issuer, or bare code on a credit asset
  • NO_PRICE_DATA: pair exists but has no recent trades

Staleness

last_trade_age_seconds is always reported, and stale is set when the age
exceeds the documented threshold ([N seconds], configurable). The agent can
distinguish a current price from an old one without parsing prose.

Testing

  • Known pair: returns price, sources, confidence, route, age, and full issuers
    for both assets.
  • Unknown pair: returns ASSET_NOT_FOUND.
  • Issuer mismatch: right code, wrong issuer returns ISSUER_MISMATCH and
    never another issuer's price.
  • Bare code on a credit asset is rejected at the schema level.
  • Native asset expressed explicitly resolves correctly.
  • Network defaults to the active network and an explicit network overrides it.
  • Stale data sets stale: true with the correct age.
  • Parity: tool output matches the REST price route for the same inputs.

Checklist

  • Price tool exists and returns the aggregated price for a pair
  • Assets given with issuer; a mismatch does not silently match
  • Network selectable and defaults sensibly
  • Staleness reported
  • Tests cover a known pair, an unknown pair, and an issuer mismatch
  • Docs updated

…y-Labs#404)

paid_call passed any URL the agent named to a fetch wrapper that answers a
402 with USDC from a live mainnet key, so a single sentence in a page, issue
comment or search result could choose where the money went.

The payable origins are server configuration (MCP_PAID_CALL_ALLOWED_HOSTS),
never a tool argument, and the default is an empty list that pays nobody.
A refused URL fails with ERR_HOST_NOT_ALLOWED naming its host, before any
request is sent, so an unlisted host is never contacted. Matching is
origin-exact — case, root dot and punycode folded, port pinned when the
entry names one — plus an optional path prefix enforced at a segment
boundary, which refuses subdomain look-alikes, url userinfo, homoglyphs and
sibling paths. An entry this cannot read is dropped with a warning rather
than guessed at, because a typo should cost an allowed host, not a blocked
one.

Bounds a spend this tool can make; the ceiling (Divinity-Labs#210) and the payment log
(Divinity-Labs#211) cover what happens to hosts that are allow-listed.
…y-Labs#423)

Existing 402 tests mocked fetchWithPayment, so the branch that actually moves
money - 402 -> construct -> sign -> submit -> resource returned - was never
exercised. This drives the real @x402/fetch transport and a real Soroban
transfer signed with @stellar/stellar-sdk against a stubbed resource + fake
facilitator. Only the RPC/Horizon and the facilitator are stubbed; both
upgrade-sensitive surfaces (x402 loop wiring, Stellar SDK signing) stay real.

Asserts on what was signed - amount, destination and network passphrase
(valid on testnet, fails on mainnet) - not just the response body, and covers
amount above maxPrice, an unregistered network, and an expired signature.
Throwaway testnet keys only; deterministic, no network access.
…ivinity-Labs#408)

The MCP server exposed bazaar_search and paid_call, so an agent connected to
Lens could pay a third party for a price but could not ask Lens for one. The
service aggregates SDEX, AMM, Soroswap and Aquarius into a VWAP and serves it
over REST, GraphQL and a websocket — none of that was reachable over MCP.

lens_get_price closes that by delegating to the same getAggregatedPrice and
getBestRoute the HTTP surfaces use, rather than growing a second price
implementation the tool could drift from.

The tool takes the same issuer discipline REST was fixed to in 41b1f62, and
GraphQL still lacks in Divinity-Labs#357: assets are CODE or CODE:ISSUER, the issuer binds
whenever both the request and the watched pair name one, and a code that
matches under a different issuer is refused with ERR_ASSET_ISSUER_MISMATCH
naming the issuer this network actually prices. Answering about the wrong
asset is the one outcome a price feed cannot be allowed, so a bare code still
resolves but the result echoes the issuers it priced.

Also: the network is selectable and defaults to the one the server runs; the
answer is structuredContent so an agent reads the number instead of parsing
English; staleness travels with the price via confidence,
lastTradeAgeSeconds and stale; and a pair with no ingested trade, or a zero
from every venue, is refused as ERR_NO_RESULTS instead of being quoted as a
price of 0.
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@Ugasutun Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Collaborator

Coordination note — ten PRs are editing one 179-line file

If your MCP PR has just gone red with a conflict, this is why, and it is my fault rather than yours.

I published 22 MCP issues (#404–#425) as if they were independent. They are not: almost all of them land in src/mcp/server.ts, which is 179 lines long. Every merge therefore conflicts everything else. The batch should have been sequenced, or scoped so the work genuinely separated — that was a mistake in how I wrote the issues, not in how you answered them.

Already merged:

Order I intend to take the rest in, so you can rebase once rather than repeatedly:

  1. feat: add configurable host allow-list to paid_call, refuse before any request #487 — paid_call host allow-list (paid_call will pay whatever URL the agent names #404). The gate that decides which hosts may be paid. This one goes next because everything else about spending is narrower than it.
  2. feat(mcp): bound cumulative spend, audit payments, serve over HTTP #491 / fix(mcp): bound cumulative daily paid-call spend, not just per call #492 — cumulative spend ceiling and payment audit (The spend cap is per call, so it is not a cap #405, Nothing records what the agent spent #406). These bound and record what happens to hosts the allow-list permits.
  3. fix: classify MCP paid-call errors by structured signals #480 — structural error classification (Errors are classified by searching the message text #410).
  4. fix(mcp): bound tool result size, stream-cap upstream reads, and report truncation explicitly #489 — bounded tool results (Tool results are unbounded #416).
  5. feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets #493 / feat: add lens_list_pairs, lens_get_pools and lens_screen_pairs tools #482 / feat: declare the resources capability and expose pairs, venues, OpenAPI spec, and status #494 — the new Lens tools and the resources capability (An agent cannot ask Lens for a price #408, No tool exposes pairs, pools or the screener #409, The server advertises only tools, not resources #418).
  6. test(mcp): add end-to-end 402 payment loop test against a stub facilitator #490 — the end-to-end 402 loop test (No test drives a real 402 payment loop #423).

Please do not all rebase at once. If you are below the one currently in flight, wait until the one above you lands — otherwise you rebase, I merge someone else, and you rebase again. I will comment on your PR when it is your turn.

One thing to settle between you: src/mcp/codes.ts is being extended by at least four of these, and #488 has already added a separate set of refusal reasons in src/net/ssrf.ts. We should end up with one vocabulary for "I refused to do that", not four. Whoever lands next, please look at what is already there and extend it rather than adding a parallel set — and say in your PR description what you added, so the next person can see it without reading the diff.

Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had.

@Miracle656

Copy link
Copy Markdown
Collaborator

#487 has landed — the paid_call host allow-list. You are next in the MCP queue; please rebase on current main.

I resolved #487's conflicts myself because nine PRs were stuck behind it. Three things on main now that will shape your rebase:

1. Two refusal codes, deliberately separate.

ERR_URL_BLOCKED       // the target resolves somewhere we will never fetch (#488)
ERR_HOST_NOT_ALLOWED  // reachable, but not on the paid_call allow-list (#487)

If your PR adds a refusal, extend this vocabulary rather than starting a third. "I cannot reach there" and "I will not pay there" are different answers; whatever you add should be a third distinct answer, not a synonym for one of these.

2. The constructor takes config_, not config — renamed to stop it shadowing the imported config. Both gates initialise there: paidCallRules and bazaarFetchOptions.

3. src/mcp/ is exempt from the console.* guard, and must stay exempt. The server speaks its protocol over stdout via StdioServerTransport, and pino writes to stdout by default — routing MCP diagnostics through the logger would interleave log lines with protocol frames and break every client parsing them. Use console.error there. Everywhere else in the repo, use the logger; the guard will tell you.

Push the rebase and I will review it. If it conflicts in a way that is not obvious, say so and I will resolve it rather than leave you stuck.

The merge of main into this branch left two conflict-resolution artefacts
behind: the opening `/**` of the bazaarFetch doc comment was eaten and my
old field/constructor fragment was kept alongside main's
`constructor(config_)`, so the class had two constructors and tsc stopped
at the first line of the body. codes.ts kept ERR_HOST_NOT_ALLOWED twice —
once from each side — which is TS1117.

Both are restored to main's shape, with the lens_get_price additions
reapplied on top.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the best tool description I have read, and for an MCP tool that is most of the work.

It tells the agent the thing agents get wrong about Stellar:

Always give the issuer for a token: asset codes are not unique on Stellar, anyone can issue "USDC", and a bare code matches whichever pair of that code this network watches. If the code is watched under a different issuer this tool answers ERR_ASSET_ISSUER_MISMATCH naming the issuer it does watch — it never prices a same-named asset.

That is the #357 bug stated as a contract and then enforced, not just avoided. And the refusal is genuinely implemented, not merely documented — nameClash finds the same-code pair and returns the mismatch naming both what was asked for and what is actually watched, so the agent can correct itself in one turn instead of guessing.

Three more things that will not look clever until they save someone:

orientation: 'quote-per-base' as a constant in the result, with direction saying whether the pair matched as asked or reversed. A model that ignores base/quote and reads the number backwards gets an inverted price — and an inverted price is not obviously wrong, it is just wrong. Stating the orientation in every response removes the ambiguity rather than hoping.

Refusals are deterministic: ERR_INVALID_ASSET, ERR_PAIR_NOT_WATCHED, ERR_ASSET_ISSUER_MISMATCH, ERR_NO_RESULTS. Report those rather than retrying — a refused pair is not a transient failure.

Agents retry by default. Telling it which errors are terminal is the difference between one turn and twelve, and it is the kind of instruction nobody thinks to write until they have watched an agent loop.

Confidence is defined in the description — high under 30s, medium under 300s, low beyond, unknown when nothing is ingested — with an explicit instruction to check lastTradeAgeSeconds and stale before treating a price as current. An agent cannot judge staleness it has not been told about.

And the examples carry the full issuer, USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN, not a shortened one. That rule exists here because a shortened id in an issue once led five contributors to invent plausible-looking addresses.

This closes #408 and is the first tool that makes the MCP server about Lens rather than about someone else's Bazaar node.

41 tests pass, tsc --noEmit clean, verified against current main.

@Miracle656
Miracle656 merged commit 3b86612 into Divinity-Labs:main Oct 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An agent cannot ask Lens for a price

2 participants