Repository navigation
feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets - #493
Conversation
…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.
|
@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! 🚀 |
Coordination note — ten PRs are editing one 179-line fileIf 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 Already merged:
Order I intend to take the rest in, so you can rebase once rather than repeatedly:
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: Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had. |
|
#487 has landed — the I resolved #487's conflicts myself because nine PRs were stuck behind it. Three things on 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 3. 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
left a comment
There was a problem hiding this comment.
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_MISMATCHnaming 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.
Closes #408
Summary
The MCP server exposed only
bazaar_searchandpaid_call, so an agentconnected 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_pricetool that returns the aggregatedprice for a pair as structured content.
Changes
lens_get_priceMCP tool, registered alongside the existing tools.query logic is reimplemented in the MCP layer.
for clients that ignore structured content):
price: the VWAP value, as a string to avoid float precision losssources: which venues contributed (SDEX, AMM, Soroswap, Aquarius)confidence: the aggregated confidence valuelast_trade_age_seconds: age of the most recent trade usedstale: boolean derived from a documented staleness thresholdbest_route: the best route for the pairbase/quote: the resolved assets, each with full issuernetwork: the network the price was computed onaggregated 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)
baseandquoteare objects, not strings:{ code: string, issuer?: string }.issueris required for every non-native asset. Native XLM is expressedexplicitly (
{ code: "XLM" }/native: true), never by omitting the issueron a credit asset.
inputSchema(
oneOfnative | code+issuer) and again by runtime validation. It is neverresolved by guessing.
G..., 56 chars).(code, issuer)tuple. If the code exists butunder a different issuer, the tool returns a structured error, not the
other issuer's price. Results always echo the full issuer back.
Network
networkargument, enum["pubnet", "testnet"].it got.
Errors
Structured MCP errors with stable codes so an agent can explain rather than
retry:
ASSET_NOT_FOUND: no such asset or pairISSUER_MISMATCH: code exists but not under the given issuerINVALID_ASSET: malformed code or issuer, or bare code on a credit assetNO_PRICE_DATA: pair exists but has no recent tradesStaleness
last_trade_age_secondsis always reported, andstaleis set when the ageexceeds the documented threshold (
[N seconds], configurable). The agent candistinguish a current price from an old one without parsing prose.
Testing
for both assets.
ASSET_NOT_FOUND.ISSUER_MISMATCHandnever another issuer's price.
networkoverrides it.stale: truewith the correct age.Checklist