diff --git a/.changeset/mcp-lens-get-price.md b/.changeset/mcp-lens-get-price.md new file mode 100644 index 00000000..af49820e --- /dev/null +++ b/.changeset/mcp-lens-get-price.md @@ -0,0 +1,11 @@ +--- +'lens': minor +--- + +Add the `lens_get_price` MCP tool, so an agent connected over MCP can ask Lens for +an aggregated price instead of only searching and paying other people's Bazaar +nodes. Assets are `CODE` or `CODE:ISSUER`, the issuer binds when both sides name +one (a same-named asset is refused with `ERR_ASSET_ISSUER_MISMATCH`, never +priced), the network defaults to the one the server runs, and the answer is +structured content carrying the price, venues, source count, confidence, the age +of the last trade and the best route. diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts index 1e4408b9..0a6c2e7c 100644 --- a/examples/mcp-server/agent.ts +++ b/examples/mcp-server/agent.ts @@ -86,6 +86,27 @@ async function main() { const BAZAAR_URL = 'http://localhost:3000'; + // Step 0 is the one that needs no Bazaar node and no payment: Lens answering + // for its own data. It reads the ingestion database, so a machine without one + // gets a deterministic refusal instead of a number. + console.log('\n[agent] 0. Asking Lens directly for a price...'); + + const priceResult = await client.callTool({ + name: 'lens_get_price', + arguments: { assetA: 'native', assetB: 'USDC' }, + }); + + if (priceResult.isError) { + console.log('[agent] lens_get_price refused:', JSON.parse(priceResult.content[0].text).reason); + } else { + const price: any = priceResult.structuredContent ?? JSON.parse(priceResult.content[0].text); + console.log( + `[agent] ${price.pairKey} on ${price.network}: ${price.price} ${price.quote.code} per ${price.base.code} ` + + `(confidence ${price.confidence}, ${price.sources} source(s), last trade ${price.lastTradeAgeSeconds}s ago, ` + + `route ${price.bestRoute?.route ?? 'unknown'})` + ); + } + console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); console.log(`[agent] Executing tool: bazaar_search`); diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ef9da239..a275fbf2 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,5 +1,7 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +import { createLensPriceQueries } from '../../src/mcp/priceQueries'; +import { activeNetwork } from '../../src/config'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; @@ -42,11 +44,15 @@ async function main() { const server = new BazaarMcpServer({ fetchWithPayment, + paidCallAllowlist, + // Lens's own aggregated prices, read from the same Postgres the API serves. + priceQueries: createLensPriceQueries(), paidCallAllowlist }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); console.error( paidCallAllowlist.length > 0 ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index c9a76967..cacfe590 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,6 +6,9 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_INVALID_ASSET: 'ERR_INVALID_ASSET', + ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED', + ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH', // Refused before any request was made, for two different reasons: // ERR_URL_BLOCKED the target resolves somewhere we will never fetch // (private, loopback, link-local, metadata) — #488 diff --git a/src/mcp/priceQueries.ts b/src/mcp/priceQueries.ts new file mode 100644 index 00000000..c7f823c9 --- /dev/null +++ b/src/mcp/priceQueries.ts @@ -0,0 +1,42 @@ +import { getAggregatedPrice } from '../aggregator/vwap' +import { getBestRoute } from '../aggregator/bestRoute' +import { activeNetwork, getNetworkConfig } from '../config' +import type { NetworkName } from '../config' +import type { LensPriceQueries } from './priceTool.js' + +/** + * Order size the route is computed for. REST's `/price` and `/route` default to + * the same 1000 units, so an agent reading the MCP tool and an operator reading + * the HTTP response see one number rather than two. + */ +const ROUTE_AMOUNT = 1000 + +/** + * The data layer behind `lens_get_price`, built from the same aggregation the + * REST and GraphQL surfaces serve. + * + * Keeping this in its own module is what lets `server.ts` stay free of the + * database: the tool's logic is testable with a stub, and only a process that + * opts into serving Lens's own prices pays for importing `pg` and Prisma. + */ +export function createLensPriceQueries(): LensPriceQueries { + return { + activeNetwork, + + // The declared pair list for that network, not the process-wide runtime + // registry: entries added through POST /pairs are registered against + // whichever network the API process runs, so reading them here would let a + // mainnet request resolve a pair that was only ever added on testnet. + watchedPairs(network: NetworkName) { + return getNetworkConfig(network).pairs + }, + + aggregatedPrice(pairKey: string, network: NetworkName) { + return getAggregatedPrice(pairKey, network) + }, + + bestRoute(assetA, assetB, pairKey, network) { + return getBestRoute(assetA, assetB, pairKey, ROUTE_AMOUNT, network) + }, + } +} diff --git a/src/mcp/priceTool.ts b/src/mcp/priceTool.ts new file mode 100644 index 00000000..3f5d3104 --- /dev/null +++ b/src/mcp/priceTool.ts @@ -0,0 +1,439 @@ +import { MCP_ERROR_CODES } from './codes.js' +import type { McpErrorCode } from './codes.js' +import type { AssetId, RouteInfo, WatchedPair } from '../types.js' +import type { NetworkName } from '../config.js' + +/** + * #408 — the MCP server was a client for other people's Bazaar nodes and had no + * way to answer the one question Lens exists to answer. Everything below is the + * pure half of that tool: argument parsing, pair resolution, and the shape of + * the answer. The data access lives in ./priceQueries.ts and reaches this + * module only through `LensPriceQueries`, so the tool can be exercised without + * a database and cannot accidentally grow its own price maths. + */ + +export const LENS_PRICE_TOOL_NAME = 'lens_get_price' + +/** What `getAggregatedPrice` returns; mirrored here to keep this module data-layer-free. */ +export interface AggregatedPriceFields { + price: number + sdexPrice: number + ammPrice: number + volume24h: number + sdexVolume24h: number + ammVolume24h: number + vwap1m: number + vwap5m: number + vwap1h: number + vwap24h: number + priceChange24h: number + sources: number + confidence: 'high' | 'medium' | 'low' | 'unknown' + lastTradeAgeSeconds: number | null + stale: boolean +} + +/** The three reads `lens_get_price` needs, supplied by the process that hosts the tool. */ +export interface LensPriceQueries { + /** Network the caller gets when they name none — the deployment's own default. */ + readonly activeNetwork: NetworkName + /** Pairs watched on that network, issuers intact. */ + watchedPairs(network: NetworkName): readonly WatchedPair[] + aggregatedPrice(pairKey: string, network: NetworkName): Promise + bestRoute(assetA: AssetId, assetB: AssetId, pairKey: string, network: NetworkName): Promise +} + +/** An asset as the caller wrote it, after shape validation. */ +export interface AssetRef { + code: string + issuer: string | null +} + +/** + * A type alias rather than an interface, so it carries an implicit index + * signature and satisfies the MCP `structuredContent` record type. + */ +export type LensPriceResult = { + network: NetworkName + pairKey: string + /** The watched pair's base side, with the issuer that actually defines it. */ + base: AssetRef + /** The watched pair's quote side. `price` is units of this per one unit of base. */ + quote: AssetRef + /** Whether the pair was matched in the order asked, or the other way round. */ + direction: 'as-requested' | 'reversed' + /** Constant, so the number cannot be read backwards by an agent that ignores base/quote. */ + orientation: 'quote-per-base' + price: number + sources: number + confidence: AggregatedPriceFields['confidence'] + lastTradeAgeSeconds: number | null + stale: boolean + vwap1m: number + vwap5m: number + vwap1h: number + vwap24h: number + priceChange24h: number + volume24h: number + sdexPrice: number + ammPrice: number + sdexVolume24h: number + ammVolume24h: number + bestRoute: RouteInfo | null + lastUpdated: string +} + +export type PriceToolFailure = { code: McpErrorCode; reason: string } +export type PriceToolOutcome = + | { ok: true; result: LensPriceResult } + | { ok: false; error: PriceToolFailure } + +const VALID_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] + +/** + * `native`, or an uppercase alphanumeric code with an optional full strkey + * issuer. Codes are matched case-sensitively because that is how Stellar stores + * them — "usdc" and "USDC" are different strings on the ledger — so a lowercase + * code is refused rather than quietly folded. + */ +const ASSET_PATTERN = '^(?:native|[A-Z][A-Z0-9]{0,11}(?::G[A-Z2-7]{55})?)$' +const ASSET_RE = new RegExp(ASSET_PATTERN) +const ISSUER_RE = /^G[A-Z2-7]{55}$/ + +/** How many pair keys an unknown-pair refusal lists back to the caller. */ +const PAIR_HINT_LIMIT = 20 + +export const lensPriceToolDefinition = () => ({ + name: LENS_PRICE_TOOL_NAME, + description: + 'Aggregated Lens price for one watched asset pair on one Stellar network: volume-weighted price ' + + 'across SDEX and AMM venues, with the venue split, source count, confidence, the age of the last ' + + 'trade and the best route for a 1000-unit order. Read-only — it spends nothing and needs no ' + + 'allow-listed host, so use this instead of paid_call whenever the question is "what is the price". ' + + 'Assets are CODE or CODE:ISSUER (use "native" or "XLM" for lumens). 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. The result states which pair matched (base/quote with issuers) and the ' + + 'orientation: price is units of quote per one base. ' + + 'Network is "testnet" or "mainnet", defaulting to the network this server runs. ' + + 'A price is only returned when the pair has data: confidence is high under 30s of last-trade age, ' + + 'medium under 300s, low at or beyond 300s (stale: true), and unknown when nothing has been ' + + 'ingested; check lastTradeAgeSeconds and stale before treating a price as current. 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.', + inputSchema: { + type: 'object', + properties: { + assetA: { + type: 'string', + description: 'First asset: CODE, CODE:ISSUER, or "native" for lumens. Codes are uppercase.', + pattern: ASSET_PATTERN, + examples: ['XLM', 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'], + }, + assetB: { + type: 'string', + description: 'Second asset, same form as assetA. Order does not matter; the pair is matched both ways.', + pattern: ASSET_PATTERN, + examples: ['USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'], + }, + network: { + type: 'string', + enum: [...VALID_NETWORKS], + description: 'Stellar network to quote. Defaults to the network this server is configured for.', + }, + }, + required: ['assetA', 'assetB'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + network: { type: 'string', enum: [...VALID_NETWORKS] }, + pairKey: { type: 'string' }, + base: codeIssuerSchema(), + quote: codeIssuerSchema(), + direction: { type: 'string', enum: ['as-requested', 'reversed'] }, + orientation: { type: 'string', enum: ['quote-per-base'] }, + price: { type: 'number' }, + sources: { type: 'integer' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low', 'unknown'] }, + lastTradeAgeSeconds: { type: ['number', 'null'] }, + stale: { type: 'boolean' }, + vwap1m: { type: 'number' }, + vwap5m: { type: 'number' }, + vwap1h: { type: 'number' }, + vwap24h: { type: 'number' }, + priceChange24h: { type: 'number' }, + volume24h: { type: 'number' }, + sdexPrice: { type: 'number' }, + ammPrice: { type: 'number' }, + sdexVolume24h: { type: 'number' }, + ammVolume24h: { type: 'number' }, + bestRoute: { + description: 'Best route for a 1000-unit order, or null when no venue could price the pair.', + anyOf: [routeSchema(), { type: 'null' }], + }, + lastUpdated: { type: 'string' }, + }, + required: [ + 'network', 'pairKey', 'base', 'quote', 'direction', 'orientation', 'price', 'sources', + 'confidence', 'lastTradeAgeSeconds', 'stale', 'vwap1m', 'vwap5m', 'vwap1h', 'vwap24h', + 'priceChange24h', 'volume24h', 'sdexPrice', 'ammPrice', 'sdexVolume24h', 'ammVolume24h', + 'bestRoute', 'lastUpdated', + ], + additionalProperties: false, + }, +}) + +function codeIssuerSchema() { + return { + type: 'object', + properties: { + code: { type: 'string' }, + issuer: { type: ['string', 'null'] }, + }, + required: ['code', 'issuer'], + additionalProperties: false, + } +} + +function routeSchema() { + return { + type: 'object', + properties: { + route: { type: 'string', enum: ['SDEX', 'AMM', 'SPLIT', 'UNKNOWN'] }, + sdexPrice: { type: 'number' }, + ammPrice: { type: 'number' }, + estimatedOutput: { type: 'number' }, + slippagePct: { type: 'number' }, + recommendation: { type: 'string' }, + }, + required: ['route', 'sdexPrice', 'ammPrice', 'estimatedOutput', 'slippagePct', 'recommendation'], + additionalProperties: false, + } +} + +/** + * Validate one asset argument. `native` is accepted in any case because it names + * lumens and cannot be reissued by anyone; everything else must match the + * declared shape exactly, since folding case or padding an issuer is how a + * request ends up priced about the wrong asset. + */ +export function parseAssetRef(raw: unknown, field: string): { asset: AssetRef } | PriceToolFailure { + if (typeof raw !== 'string' || raw.length === 0) { + return { + code: MCP_ERROR_CODES.ERR_INVALID_ASSET, + reason: `${field} must be a non-empty string: CODE, CODE:ISSUER, or "native"`, + } + } + + if (raw.toLowerCase() === 'native') return { asset: { code: 'XLM', issuer: null } } + + if (!ASSET_RE.test(raw)) { + const issuer = raw.split(':')[1] + if (raw !== raw.toUpperCase() && ASSET_RE.test(raw.toUpperCase())) { + return { + code: MCP_ERROR_CODES.ERR_INVALID_ASSET, + reason: + `${field}="${raw}" is not accepted: Stellar asset codes are case-sensitive, so a lowercase ` + + `code is a different asset. Resend it uppercase ("${raw.toUpperCase()}") if that is the code you mean.`, + } + } + if (issuer !== undefined && !ISSUER_RE.test(issuer)) { + return { + code: MCP_ERROR_CODES.ERR_INVALID_ASSET, + reason: + `${field}="${raw}" carries an issuer that is not a Stellar account id. An issuer must be the ` + + `full G... public key of the issuing account; a partial or abbreviated one is refused.`, + } + } + return { + code: MCP_ERROR_CODES.ERR_INVALID_ASSET, + reason: + `${field}="${raw}" is not an asset reference. Expected "native", an uppercase alphanumeric ` + + `code of up to 12 characters, or CODE:ISSUER with the issuer's full G... account id.`, + } + } + + const [code, issuer] = raw.split(':') + return { asset: { code, issuer: issuer ?? null } } +} + +function codeMatches(q: AssetRef, side: AssetRef): boolean { + return q.code === side.code.toUpperCase() +} + +/** + * REST's issuer rule, kept identical: the issuer binds only when both the + * request and the watched pair name one. XLM has no issuer, and a caller who + * passes a bare code has asserted nothing about which issuer they meant. + */ +function sideMatches(q: AssetRef, side: AssetRef): boolean { + if (!codeMatches(q, side)) return false + if (!q.issuer || !side.issuer) return true + return q.issuer === side.issuer +} + +function assetLabel(side: AssetRef): string { + return side.issuer ? `${side.code}:${side.issuer}` : side.code +} + +/** + * Resolve a request against the pairs one network watches. + * + * A code that matches but an issuer that does not is reported as its own + * failure, naming the issuer this network actually prices. That is deliberate: + * "not watched" would send the agent hunting for a pair that is there, and the + * alternative — matching on code alone — is the failure that made this repo + * quote testnet USDC for a mainnet USDC request. + */ +export function resolveWatchedPair( + assetA: AssetRef, + assetB: AssetRef, + network: NetworkName, + pairs: readonly WatchedPair[], +): { pair: WatchedPair; direction: 'as-requested' | 'reversed' } | PriceToolFailure { + const asRef = (a: AssetId): AssetRef => ({ code: a.code, issuer: a.issuer ?? null }) + + const forward = (p: WatchedPair) => + sideMatches(assetA, asRef(p.assetA)) && sideMatches(assetB, asRef(p.assetB)) + const backward = (p: WatchedPair) => + sideMatches(assetA, asRef(p.assetB)) && sideMatches(assetB, asRef(p.assetA)) + + const matched = pairs.find(p => forward(p) || backward(p)) + if (matched) { + return { pair: matched, direction: forward(matched) ? 'as-requested' : 'reversed' } + } + + const byCode = (p: WatchedPair) => + (codeMatches(assetA, asRef(p.assetA)) && codeMatches(assetB, asRef(p.assetB))) || + (codeMatches(assetA, asRef(p.assetB)) && codeMatches(assetB, asRef(p.assetA))) + + const nameClash = pairs.find(byCode) + if (nameClash) { + return { + code: MCP_ERROR_CODES.ERR_ASSET_ISSUER_MISMATCH, + reason: + `On ${network} this deployment watches ${assetLabel(nameClash.assetA)}/${assetLabel(nameClash.assetB)}, ` + + `but the request named ${assetLabel(assetA)}/${assetLabel(assetB)}. Those issuers identify different ` + + `assets, so no price is returned for them. Ask for the pair exactly as watched, or register the ` + + `pair you mean.`, + } + } + + const watched = pairs.map(p => p.pairKey).slice(0, PAIR_HINT_LIMIT) + return { + code: MCP_ERROR_CODES.ERR_PAIR_NOT_WATCHED, + reason: + `Pair ${assetLabel(assetA)}/${assetLabel(assetB)} is not watched on ${network}. ` + + (watched.length + ? `Watched on this network: ${watched.join(', ')}${pairs.length > PAIR_HINT_LIMIT ? ', ...' : ''}` + : `This deployment watches no pairs on ${network}; none can be priced.`), + } +} + +function failure(code: McpErrorCode, reason: string): PriceToolOutcome { + return { ok: false, error: { code, reason } } +} + +/** + * Run one `lens_get_price` request. Everything the tool can get wrong by + * accident — an unknown argument, a folded case, a network that is not this + * deployment's, an issuer that names a different asset — is settled here, before + * any query runs, so a refusal costs the database nothing. + */ +export async function readLensPrice( + args: unknown, + queries: LensPriceQueries, +): Promise { + if (typeof args !== 'object' || args === null || Array.isArray(args)) { + return failure(MCP_ERROR_CODES.ERR_BAD_REQUEST, 'lens_get_price expects an object of arguments') + } + + const given = args as Record + + // The low-level MCP server hands the caller's object straight to the handler, + // so `additionalProperties: false` is only a promise until something enforces + // it. Without this, a typo'd or smuggled key is silently ignored and the agent + // believes it asked for something it did not. + const allowed = ['assetA', 'assetB', 'network'] + const unexpected = Object.keys(given).filter(k => !allowed.includes(k)) + if (unexpected.length > 0) { + return failure( + MCP_ERROR_CODES.ERR_BAD_REQUEST, + `Unknown argument(s): ${unexpected.join(', ')}. lens_get_price accepts only ${allowed.join(', ')}.`, + ) + } + + const parsedA = parseAssetRef(given.assetA, 'assetA') + if ('code' in parsedA) return failure(parsedA.code, parsedA.reason) + const parsedB = parseAssetRef(given.assetB, 'assetB') + if ('code' in parsedB) return failure(parsedB.code, parsedB.reason) + + let network = queries.activeNetwork + if (given.network !== undefined) { + if (typeof given.network !== 'string' || !(VALID_NETWORKS as readonly string[]).includes(given.network)) { + return failure( + MCP_ERROR_CODES.ERR_BAD_REQUEST, + `network must be one of ${VALID_NETWORKS.join(' or ')}, got ${JSON.stringify(given.network)}`, + ) + } + network = given.network as NetworkName + } + + const resolved = resolveWatchedPair(parsedA.asset, parsedB.asset, network, queries.watchedPairs(network)) + if ('code' in resolved) return failure(resolved.code, resolved.reason) + const { pair, direction } = resolved + + const agg = await queries.aggregatedPrice(pair.pairKey, network) + + // No ingested trade, or a price of zero from every venue, is not a quote. A + // `0` served as a price is the confident wrong number this project has been + // burned by before, so it is refused with the data that explains it. + if (agg.lastTradeAgeSeconds === null || !(agg.price > 0)) { + return failure( + MCP_ERROR_CODES.ERR_NO_RESULTS, + `${pair.pairKey} is watched on ${network} but has no aggregated price: ` + + `last trade ${agg.lastTradeAgeSeconds === null ? 'never' : `${agg.lastTradeAgeSeconds}s ago`}, ` + + `price ${agg.price} across ${agg.sources} source(s).`, + ) + } + + // Routing consults Horizon as well as the database. A price that is already + // known should not be withheld because the route leg failed, so the failure is + // reported in the payload rather than thrown away or turned into an error. + const route = await queries.bestRoute(pair.assetA, pair.assetB, pair.pairKey, network).catch(() => null) + + const base = { code: pair.assetA.code, issuer: pair.assetA.issuer ?? null } + const quote = { code: pair.assetB.code, issuer: pair.assetB.issuer ?? null } + + return { + ok: true, + result: { + network, + pairKey: pair.pairKey, + base, + quote, + direction, + orientation: 'quote-per-base', + price: agg.price, + sources: agg.sources, + confidence: agg.confidence, + lastTradeAgeSeconds: agg.lastTradeAgeSeconds, + stale: agg.stale, + vwap1m: agg.vwap1m, + vwap5m: agg.vwap5m, + vwap1h: agg.vwap1h, + vwap24h: agg.vwap24h, + priceChange24h: agg.priceChange24h, + volume24h: agg.volume24h, + sdexPrice: agg.sdexPrice, + ammPrice: agg.ammPrice, + sdexVolume24h: agg.sdexVolume24h, + ammVolume24h: agg.ammVolume24h, + bestRoute: route, + lastUpdated: new Date().toISOString(), + }, + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..6f311f0c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; +import { LENS_PRICE_TOOL_NAME, lensPriceToolDefinition, readLensPrice, type LensPriceQueries } from './priceTool.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; @@ -15,6 +16,12 @@ export interface McpServerConfig { * `paid_call` pays nothing. See ./paidCallAllowlist.ts. */ paidCallAllowlist?: readonly string[]; + /** + * Lens's own aggregated prices, for `lens_get_price`. Hosts that can read the + * price data pass {@link createLensPriceQueries}; leaving it out simply does + * not advertise the tool, so an agent is never offered a price it cannot get. + */ + priceQueries?: LensPriceQueries; /** * Outbound fetch policy for `bazaar_search`. Falls back to the operator's * shared `OUTBOUND_ALLOWED_HOSTS` allowlist. `lookup`/`fetchImpl` exist so @@ -28,9 +35,11 @@ export class BazaarMcpServer { private fetchWithPayment: typeof fetch; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; + private priceQueries?: LensPriceQueries; constructor(config_: McpServerConfig) { this.fetchWithPayment = config_.fetchWithPayment; + this.priceQueries = config_.priceQueries; // Two independent gates, deliberately separate: this one decides which // hosts may be PAID (#404), the one below decides which addresses may be @@ -63,8 +72,8 @@ export class BazaarMcpServer { } private setupHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools: Record[] = [ { name: 'bazaar_search', description: 'Search for resources in a Stellar Bazaar node', @@ -107,10 +116,46 @@ export class BazaarMcpServer { additionalProperties: false, }, }, - ], - })); + ]; + + // #408: without this the server could spend money on someone else's price + // feed while being unable to state Lens's own. + if (this.priceQueries) { + tools.push(lensPriceToolDefinition()); + } + + return { tools }; + }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === LENS_PRICE_TOOL_NAME) { + if (!this.priceQueries) { + return this.createErrorResponse( + 'ERR_INTERNAL_FAILURE', + 'lens_get_price is not available: this server has no Lens price source configured.' + ); + } + + try { + const outcome = await readLensPrice(request.params.arguments, this.priceQueries); + + if (!outcome.ok) { + return this.createErrorResponse(outcome.error.code, outcome.error.reason); + } + + // structuredContent is what the agent reads; the text block keeps the + // same payload visible to clients that only render content. + return { + content: [{ type: 'text', text: JSON.stringify(outcome.result, null, 2) }], + structuredContent: outcome.result, + }; + } catch (e: any) { + // Prices come from Postgres and Horizon, so a throw here is the data + // layer being unreachable — never an answer about the pair. + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message || 'Price lookup failed.'); + } + } + if (request.params.name === 'bazaar_search') { const url = String(request.params.arguments?.url); const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; diff --git a/tests/mcp-get-price-aggregation.test.ts b/tests/mcp-get-price-aggregation.test.ts new file mode 100644 index 00000000..fc1b4a12 --- /dev/null +++ b/tests/mcp-get-price-aggregation.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { BazaarMcpServer } from '../src/mcp/server' + +/** + * `lens_get_price` has to be the same number REST and GraphQL serve, computed in + * one place. These tests stub the two aggregator modules and check the MCP tool + * delegates to them with the pair and network it resolved — so the tool cannot + * quietly grow its own price maths or read the wrong chain's rows. + */ + +const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +const CIRCLE_MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +const mocks = vi.hoisted(() => ({ + getAggregatedPrice: vi.fn(), + getBestRoute: vi.fn(), +})) + +vi.mock('../src/aggregator/vwap', () => ({ getAggregatedPrice: mocks.getAggregatedPrice })) +vi.mock('../src/aggregator/bestRoute', () => ({ getBestRoute: mocks.getBestRoute })) + +const AGG = { + price: 0.18, sdexPrice: 0.18, ammPrice: 0.18, + volume24h: 10, sdexVolume24h: 10, ammVolume24h: 0, + vwap1m: 0.18, vwap5m: 0.18, vwap1h: 0.18, vwap24h: 0.18, + priceChange24h: 0, sources: 2, + confidence: 'high' as const, lastTradeAgeSeconds: 5, stale: false, +} + +const ROUTE = { + route: 'SDEX' as const, sdexPrice: 0.18, ammPrice: 0, + estimatedOutput: 180, slippagePct: 0, recommendation: 'Only SDEX liquidity available', +} + +async function loadPriceQueries() { + vi.resetModules() + const { createLensPriceQueries } = await import('../src/mcp/priceQueries') + return createLensPriceQueries() +} + +beforeEach(() => { + process.env.STELLAR_NETWORK = 'testnet' + process.env.WATCHED_PAIRS_TESTNET = `USDC:${TESTNET_USDC}/XLM` + mocks.getAggregatedPrice.mockReset().mockResolvedValue(AGG) + mocks.getBestRoute.mockReset().mockResolvedValue(ROUTE) +}) + +describe('the MCP price source is the shared aggregator', () => { + it('reads watched pairs from the named network, not the active one', async () => { + const queries = await loadPriceQueries() + + expect(queries.activeNetwork).toBe('testnet') + expect(queries.watchedPairs('testnet').map(p => p.pairKey)).toEqual([`USDC:${TESTNET_USDC}/XLM`]) + // Mainnet carries its own default, pinned to Circle's issuer. + expect(queries.watchedPairs('mainnet').map(p => p.pairKey)).toEqual([`USDC:${CIRCLE_MAINNET_USDC}/XLM`]) + }) + + it('passes the resolved pair key and network to the aggregation query', async () => { + const queries = await loadPriceQueries() + + await queries.aggregatedPrice(`USDC:${TESTNET_USDC}/XLM`, 'mainnet') + expect(mocks.getAggregatedPrice).toHaveBeenCalledWith(`USDC:${TESTNET_USDC}/XLM`, 'mainnet') + }) + + it('routes with the same 1000-unit order size REST uses', async () => { + const queries = await loadPriceQueries() + const pair = queries.watchedPairs('testnet')[0] + + await queries.bestRoute(pair.assetA, pair.assetB, pair.pairKey, 'testnet') + expect(mocks.getBestRoute).toHaveBeenCalledWith(pair.assetA, pair.assetB, pair.pairKey, 1000, 'testnet') + }) + + it('serves the aggregated number through the tool end to end', async () => { + const queries = await loadPriceQueries() + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), priceQueries: queries }) + + let handler: any = null + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn + } + ;(server as any).setupHandlers() + + const response = await handler({ + params: { name: 'lens_get_price', arguments: { assetA: 'native', assetB: `USDC:${TESTNET_USDC}` } }, + }) + + expect(response.isError).toBeFalsy() + expect(response.structuredContent).toMatchObject({ + network: 'testnet', + pairKey: `USDC:${TESTNET_USDC}/XLM`, + price: 0.18, + confidence: 'high', + lastTradeAgeSeconds: 5, + stale: false, + bestRoute: ROUTE, + }) + // Both aggregator legs were asked about the same pair, on the same network. + expect(mocks.getAggregatedPrice).toHaveBeenCalledWith(`USDC:${TESTNET_USDC}/XLM`, 'testnet') + expect(mocks.getBestRoute).toHaveBeenCalledWith( + { code: 'USDC', issuer: TESTNET_USDC }, + { code: 'XLM', issuer: null }, + `USDC:${TESTNET_USDC}/XLM`, + 1000, + 'testnet', + ) + }) + + it('refuses a mainnet USDC request on this testnet deployment without querying', async () => { + const queries = await loadPriceQueries() + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), priceQueries: queries }) + + let handler: any = null + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn + } + ;(server as any).setupHandlers() + + const response = await handler({ + params: { name: 'lens_get_price', arguments: { assetA: 'native', assetB: `USDC:${CIRCLE_MAINNET_USDC}` } }, + }) + + expect(response.isError).toBe(true) + expect(JSON.parse(response.content[0].text).code).toBe('ERR_ASSET_ISSUER_MISMATCH') + expect(mocks.getAggregatedPrice).not.toHaveBeenCalled() + }) +}) diff --git a/tests/mcp-get-price.test.ts b/tests/mcp-get-price.test.ts new file mode 100644 index 00000000..9c013cfa --- /dev/null +++ b/tests/mcp-get-price.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect, vi } from 'vitest' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { BazaarMcpServer } from '../src/mcp/server' +import type { AggregatedPriceFields, LensPriceQueries } from '../src/mcp/priceTool' +import type { NetworkName } from '../src/config' +import type { RouteInfo, WatchedPair } from '../src/types' + +/** + * #408 — an agent connected over MCP could reach other people's Bazaar nodes but + * not Lens's own prices. These tests pin the other half of that: the tool answers + * about the asset that was actually asked about (issuer included), refuses a + * same-named asset instead of pricing it, defaults to the server's network but + * takes another one when named, and reports the age of what it returned. + */ + +const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + +/** Every tool result here is one JSON text block; the SDK types content loosely. */ +const jsonOf = (result: unknown) => + JSON.parse((result as { content: Array<{ text: string }> }).content[0].text) +const MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +const testnetPair = (): WatchedPair => ({ + assetA: { code: 'USDC', issuer: TESTNET_USDC }, + assetB: { code: 'XLM', issuer: null }, + pairKey: `USDC:${TESTNET_USDC}/XLM`, +}) + +const mainnetPair = (): WatchedPair => ({ + assetA: { code: 'USDC', issuer: MAINNET_USDC }, + assetB: { code: 'XLM', issuer: null }, + pairKey: `USDC:${MAINNET_USDC}/XLM`, +}) + +const aggregated = (over: Partial = {}): AggregatedPriceFields => ({ + price: 0.185, + sdexPrice: 0.184, + ammPrice: 0.186, + volume24h: 1200, + sdexVolume24h: 900, + ammVolume24h: 300, + vwap1m: 0.1851, + vwap5m: 0.1852, + vwap1h: 0.185, + vwap24h: 0.184, + priceChange24h: -1.2, + sources: 3, + confidence: 'high', + lastTradeAgeSeconds: 12, + stale: false, + ...over, +}) + +const route = (): RouteInfo => ({ + route: 'SDEX', + sdexPrice: 0.184, + ammPrice: 0.186, + estimatedOutput: 184, + slippagePct: 0, + recommendation: 'AMM offers 1.09% better rate', +}) + +interface HarnessOptions { + pairs?: Partial> + activeNetwork?: NetworkName + agg?: AggregatedPriceFields | Error + route?: RouteInfo | Error +} + +function buildHarness(options: HarnessOptions = {}) { + const pairs: Record = { + testnet: [testnetPair()], + mainnet: [mainnetPair()], + ...options.pairs, + } + + const settle = (value: T | Error | undefined, fallback: T): Promise => + value instanceof Error ? Promise.reject(value) : Promise.resolve(value ?? fallback) + + const watchedPairs = vi.fn((network: NetworkName) => pairs[network] ?? []) + const aggregatedPrice = vi.fn(() => settle(options.agg, aggregated())) + const bestRoute = vi.fn(() => settle(options.route, route())) + + const priceQueries: LensPriceQueries = { + activeNetwork: options.activeNetwork ?? 'testnet', + watchedPairs, + aggregatedPrice, + bestRoute, + } + + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + priceQueries, + }) + + const handlers: Record = {} + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handlers.call = fn + if (schema === ListToolsRequestSchema) handlers.list = fn + } + ;(server as any).setupHandlers() + + const call = async (arguments_: unknown) => { + const response = await handlers.call({ params: { name: 'lens_get_price', arguments: arguments_ } }) + return { + response, + body: JSON.parse(response.content[0].text), + structured: response.structuredContent, + } + } + + return { + call, + listTools: () => handlers.list(), + watchedPairs, + aggregatedPrice, + bestRoute, + priceQueries, + } +} + +/** The same tool over a server that was never given a price source. */ +function buildServerWithoutPrices() { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }) + const handlers: Record = {} + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handlers.call = fn + if (schema === ListToolsRequestSchema) handlers.list = fn + } + ;(server as any).setupHandlers() + return { + listTools: () => handlers.list(), + call: async (arguments_: unknown) => + handlers.call({ params: { name: 'lens_get_price', arguments: arguments_ } }), + } +} + +describe('lens_get_price', () => { + it('returns the aggregated price of a known pair as structured content', async () => { + const h = buildHarness() + const { response, structured } = await h.call({ + assetA: 'XLM', + assetB: `USDC:${MAINNET_USDC}`, + network: 'mainnet', + }) + + expect(response.isError).toBeFalsy() + expect(structured.price).toBe(0.185) + expect(structured.network).toBe('mainnet') + expect(structured.pairKey).toBe(`USDC:${MAINNET_USDC}/XLM`) + expect(structured.sources).toBe(3) + expect(structured.confidence).toBe('high') + expect(structured.bestRoute.route).toBe('SDEX') + // The pair is watched USDC/XLM and asked XLM/USDC: the answer says which way + // round it is stated, so the number cannot be read as its own reciprocal. + expect(structured.direction).toBe('reversed') + expect(structured.orientation).toBe('quote-per-base') + expect(structured.base).toEqual({ code: 'USDC', issuer: MAINNET_USDC }) + expect(structured.quote).toEqual({ code: 'XLM', issuer: null }) + }) + + it('queries the resolved pair, not the string the caller typed', async () => { + const h = buildHarness() + await h.call({ assetA: 'native', assetB: 'USDC' }) + + expect(h.watchedPairs).toHaveBeenCalledWith('testnet') + expect(h.aggregatedPrice).toHaveBeenCalledWith(`USDC:${TESTNET_USDC}/XLM`, 'testnet') + expect(h.bestRoute).toHaveBeenCalledWith( + { code: 'USDC', issuer: TESTNET_USDC }, + { code: 'XLM', issuer: null }, + `USDC:${TESTNET_USDC}/XLM`, + 'testnet', + ) + }) + + it('names the issuer it priced when the caller passes a bare code', async () => { + const { structured } = await buildHarness().call({ assetA: 'native', assetB: 'USDC' }) + + // Bare codes still resolve — XLM has no issuer and single-issuer assets are + // quoted by code — but the answer must say which USDC it is about. + expect(structured.base).toEqual({ code: 'USDC', issuer: TESTNET_USDC }) + }) + + it('does not answer a mainnet USDC request from the testnet pair', async () => { + const h = buildHarness() + const { response, body } = await h.call({ + assetA: 'native', + assetB: `USDC:${MAINNET_USDC}`, + network: 'testnet', + }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_ASSET_ISSUER_MISMATCH') + // The refusal is actionable: it names the issuer this network does price, + // so the agent can correct the request instead of guessing. + expect(body.reason).toContain(TESTNET_USDC) + expect(h.aggregatedPrice).not.toHaveBeenCalled() + }) + + it('prices the same code correctly when the network that watches it is named', async () => { + const h = buildHarness() + const { response, structured } = await h.call({ + assetA: 'native', + assetB: `USDC:${MAINNET_USDC}`, + network: 'mainnet', + }) + + expect(response.isError).toBeFalsy() + expect(structured.pairKey).toBe(`USDC:${MAINNET_USDC}/XLM`) + }) + + it('refuses a pair nothing watches, and says what is watched instead', async () => { + const h = buildHarness() + const { response, body } = await h.call({ assetA: 'XLM', assetB: 'ABCD' }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_PAIR_NOT_WATCHED') + expect(body.reason).toContain(`USDC:${TESTNET_USDC}/XLM`) + expect(h.aggregatedPrice).not.toHaveBeenCalled() + }) + + it('defaults to the network the server runs and takes another when named', async () => { + const h = buildHarness({ activeNetwork: 'mainnet' }) + + const defaulted = await h.call({ assetA: 'native', assetB: 'USDC' }) + expect(defaulted.structured.network).toBe('mainnet') + + const named = await h.call({ assetA: 'native', assetB: 'USDC', network: 'testnet' }) + expect(named.structured.network).toBe('testnet') + expect(named.structured.pairKey).toBe(`USDC:${TESTNET_USDC}/XLM`) + }) + + it('refuses a network that does not exist before reading anything', async () => { + const h = buildHarness() + for (const network of ['public', 'pubnet', 'Mainnet', '', 3]) { + const { response, body } = await h.call({ assetA: 'native', assetB: 'USDC', network }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_BAD_REQUEST') + expect(body.reason).toMatch(/testnet or mainnet/) + } + expect(h.watchedPairs).not.toHaveBeenCalled() + }) + + it('reports staleness rather than presenting an old price as current', async () => { + const h = buildHarness({ + agg: aggregated({ lastTradeAgeSeconds: 4200, stale: true, confidence: 'low', price: 0.17 }), + }) + const { response, structured } = await h.call({ assetA: 'native', assetB: 'USDC' }) + + expect(response.isError).toBeFalsy() + expect(structured.lastTradeAgeSeconds).toBe(4200) + expect(structured.stale).toBe(true) + expect(structured.confidence).toBe('low') + }) + + it('refuses to quote a pair with no ingested trade, or a price of zero', async () => { + for (const agg of [ + aggregated({ lastTradeAgeSeconds: null, confidence: 'unknown', sources: 0 }), + aggregated({ price: 0 }), + ]) { + const h = buildHarness({ agg }) + const { response, body } = await h.call({ assetA: 'native', assetB: 'USDC' }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_NO_RESULTS') + // A 0 is not a price; it is the absence of one, and an agent that trades on + // it loses money. + expect(body.reason).toMatch(/no aggregated price/) + } + }) + + it('still returns the price when only the route leg fails', async () => { + const h = buildHarness({ route: new Error('No pricing data available') }) + const { response, structured } = await h.call({ assetA: 'native', assetB: 'USDC' }) + + expect(response.isError).toBeFalsy() + expect(structured.price).toBe(0.185) + expect(structured.bestRoute).toBeNull() + }) + + it('reports an unreachable data source as a service failure, not a price', async () => { + const h = buildHarness({ agg: new Error('connection refused') }) + const { response, body } = await h.call({ assetA: 'native', assetB: 'USDC' }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_SERVICE_UNREACHABLE') + }) + + describe('rejects an asset it cannot identify', () => { + const badAssets = [ + ['usdc', /case-sensitive/], + [`USDC:${TESTNET_USDC.slice(0, 20)}`, /issuer that is not a Stellar account id/], + ['USDC:', /issuer that is not a Stellar account id/], + ['', /non-empty string/], + ['TOO.LONG-CODE', /not an asset reference/], + ['1USDC', /not an asset reference/], + [42, /non-empty string/], + ] as const + + for (const [assetA, pattern] of badAssets) { + it(`refuses ${JSON.stringify(assetA)} without asking the database`, async () => { + const h = buildHarness() + const { response, body } = await h.call({ assetA, assetB: 'XLM' }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_INVALID_ASSET') + expect(body.reason).toMatch(pattern) + expect(h.watchedPairs).not.toHaveBeenCalled() + }) + } + + it('requires both sides of the pair', async () => { + const h = buildHarness() + const { body } = await h.call({ assetA: 'native' }) + + expect(body.code).toBe('ERR_INVALID_ASSET') + expect(body.reason).toContain('assetB') + }) + + it('prices a native request written either way', async () => { + for (const native of ['native', 'Native', 'XLM']) { + const { structured } = await buildHarness().call({ assetA: native, assetB: 'USDC' }) + expect(structured.quote).toEqual({ code: 'XLM', issuer: null }) + } + }) + }) + + describe('argument strictness', () => { + it('refuses an argument the tool does not accept', async () => { + const h = buildHarness() + const { response, body } = await h.call({ + assetA: 'native', + assetB: 'USDC', + issuer: MAINNET_USDC, + network: 'mainnet', + }) + + expect(response.isError).toBe(true) + expect(body.code).toBe('ERR_BAD_REQUEST') + expect(body.reason).toContain('issuer') + expect(h.aggregatedPrice).not.toHaveBeenCalled() + }) + + it('refuses arguments that are not an object', async () => { + for (const args of [undefined, null, 'native/USDC', ['native', 'USDC']]) { + const { body } = await buildHarness().call(args) + expect(body.code).toBe('ERR_BAD_REQUEST') + } + }) + }) + + describe('tool surface', () => { + it('accepts exactly the pair and the network', async () => { + const tools = (await buildHarness().listTools()).tools + const price = tools.find((t: any) => t.name === 'lens_get_price') + + expect(Object.keys(price.inputSchema.properties).sort()).toEqual(['assetA', 'assetB', 'network']) + expect(price.inputSchema.required).toEqual(['assetA', 'assetB']) + expect(price.inputSchema.additionalProperties).toBe(false) + expect(price.inputSchema.properties.network.enum).toEqual(['testnet', 'mainnet']) + expect(price.outputSchema.required).toEqual(expect.arrayContaining(['price', 'confidence', 'stale'])) + expect(price.description).toMatch(/issuer/i) + expect(price.description).toMatch(/stale/i) + }) + + it('is not advertised, and says so if called anyway, when no price source is configured', async () => { + const server = buildServerWithoutPrices() + + const tools = (await server.listTools()).tools + expect(tools.map((t: any) => t.name)).not.toContain('lens_get_price') + + const response = await server.call({ assetA: 'native', assetB: 'USDC' }) + expect(response.isError).toBe(true) + expect(JSON.parse(response.content[0].text).code).toBe('ERR_INTERNAL_FAILURE') + }) + }) +}) + +/** + * The handler-level tests above drive the server the way its own code does. This + * one drives it the way an agent does — a real MCP client over a linked + * transport — because the point of #408 is that a connected agent could not get + * a price at all. It catches a declaration the SDK will not accept, or a result + * shape a client will not parse, which neither of the other layers can see. + */ +describe('lens_get_price through an MCP client', () => { + async function connect(queries: LensPriceQueries) { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), priceQueries: queries }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await (server as any).server.connect(serverTransport) + + const client = new Client({ name: 'test-agent', version: '1.0.0' }, { capabilities: {} }) + await client.connect(clientTransport) + return client + } + + it('is advertised, called, and answered with structured content', async () => { + const { priceQueries } = buildHarness() + const client = await connect(priceQueries) + + const tools = await client.listTools() + const declared = tools.tools.find(t => t.name === 'lens_get_price') + expect(declared).toBeDefined() + expect(declared!.outputSchema).toBeDefined() + + const result = await client.callTool({ + name: 'lens_get_price', + arguments: { assetA: 'native', assetB: `USDC:${TESTNET_USDC}` }, + }) + + expect(result.isError).toBeFalsy() + expect((result.structuredContent as any).price).toBe(0.185) + // A client that only renders text still reads the same answer. + expect(jsonOf(result).pairKey).toBe(`USDC:${TESTNET_USDC}/XLM`) + + const refused = await client.callTool({ + name: 'lens_get_price', + arguments: { assetA: 'native', assetB: `USDC:${MAINNET_USDC}` }, + }) + expect(refused.isError).toBe(true) + expect(jsonOf(refused).code).toBe('ERR_ASSET_ISSUER_MISMATCH') + }) + + it('refuses an argument the schema does not declare', async () => { + const { priceQueries, aggregatedPrice } = buildHarness() + const client = await connect(priceQueries) + + const result = await client.callTool({ + name: 'lens_get_price', + arguments: { assetA: 'native', assetB: 'USDC', pairKey: 'XLM/USDC' }, + }) + + expect(result.isError).toBe(true) + expect(jsonOf(result).code).toBe('ERR_BAD_REQUEST') + expect(aggregatedPrice).not.toHaveBeenCalled() + }) +})