From c753139ce0cb6359138cbf455b0254d4d4d7192a Mon Sep 17 00:00:00 2001 From: Stehen Jeremiah Haruna <141843972+Ugasutun@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:16:26 +0100 Subject: [PATCH] feat(mcp): expose pairs, pools and screener as MCP tools (#409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents could ask for a price but had no way to discover what is quotable, what liquidity sits behind a pair, or what is moving — REST answered all three while MCP answered none, leaving agents to guess asset codes. lens_list_pairs, lens_get_pools and lens_screen_pairs call the same listPairs/listPools/screenPairs the REST routes now delegate to, so the two transports cannot drift apart. Each result is bounded by a limit (max 100) and carries nextCursor/hasMore, assets always include the full issuer, and the tool descriptions spell out which question each one answers. --- .changeset/mcp-lens-tools.md | 9 + src/__tests__/pools.test.ts | 13 +- src/api/rest.ts | 36 ---- src/app.ts | 2 + src/mcp/lensTools.ts | 311 +++++++++++++++++++++++++++++ src/mcp/server.ts | 8 + src/routes/pairs.ts | 153 +++++++++----- src/routes/pools.ts | 132 ++++++++++++ src/routes/screener.ts | 376 +++++++++++++++++++---------------- tests/mcp-lens.test.ts | 365 ++++++++++++++++++++++++++++++++++ 10 files changed, 1136 insertions(+), 269 deletions(-) create mode 100644 .changeset/mcp-lens-tools.md create mode 100644 src/mcp/lensTools.ts create mode 100644 src/routes/pools.ts create mode 100644 tests/mcp-lens.test.ts diff --git a/.changeset/mcp-lens-tools.md b/.changeset/mcp-lens-tools.md new file mode 100644 index 00000000..121fd633 --- /dev/null +++ b/.changeset/mcp-lens-tools.md @@ -0,0 +1,9 @@ +--- +'lens': minor +--- + +Add MCP tools `lens_list_pairs`, `lens_get_pools` and `lens_screen_pairs` (#409). Each tool calls the same +implementation as its REST route — `/pairs`, `/pools` and `/screener` now share `listPairs`, `listPools` and +`screenPairs` — so answers cannot drift between transports. Results are bounded by a `limit` (max 100, default +50/50/20) and carry `nextCursor`/`hasMore` for the next page. Assets in tool results always include the full +issuer, never a shortened form. Tool descriptions state when to use each one. diff --git a/src/__tests__/pools.test.ts b/src/__tests__/pools.test.ts index 1531988f..cbdf36bc 100644 --- a/src/__tests__/pools.test.ts +++ b/src/__tests__/pools.test.ts @@ -7,16 +7,7 @@ vi.mock('../db', () => ({ pgPool: { query: mockQuery }, })) -vi.mock('../redis', () => ({ - getCachedPrice: vi.fn(), - setCachedPrice: vi.fn(), -})) - -vi.mock('../aggregator/bestRoute', () => ({ - getBestRoute: vi.fn(), -})) - -import { registerRESTRoutes } from '../api/rest' +import { registerPoolsRoutes } from '../routes/pools' import { activeNetwork } from '../config' async function buildApp(network?: 'testnet' | 'mainnet') { @@ -27,7 +18,7 @@ async function buildApp(network?: 'testnet' | 'mainnet') { req.network = network }) } - await registerRESTRoutes(app) + await registerPoolsRoutes(app) await app.ready() return app } diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..26faf2cd 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -11,7 +11,6 @@ import { priceResponseSchema, routeResponseSchema, historyResponseSchema, - poolsResponseSchema, depthResponseSchema, installResponseValidation, } from './schemas' @@ -204,41 +203,6 @@ export async function registerRESTRoutes(app: FastifyInstance) { } ) - // GET /pools - // - // Latest snapshot per pool for one network, without reading history. The pool - // ids are discovered with a recursive "skip scan" over the - // (network, pool_id, timestamp DESC) index (jump to the next distinct pool_id - // each step), then one LIMIT 1 index probe per pool fetches its newest row. - // Cost is O(pools x log n); the old DISTINCT ON sorted every row of every - // network. A quiet pool is kept with its real `timestamp`, never dropped. - app.get('/pools', { schema: { response: { 200: poolsResponseSchema } } }, async (req) => { - const network = req.network ?? activeNetwork - const result = await pgPool.query( - `WITH RECURSIVE ids AS ( - (SELECT pool_id FROM pool_snapshots WHERE network = $1 ORDER BY pool_id LIMIT 1) - UNION ALL - SELECT (SELECT s.pool_id FROM pool_snapshots s - WHERE s.network = $1 AND s.pool_id > ids.pool_id - ORDER BY s.pool_id LIMIT 1) - FROM ids - WHERE ids.pool_id IS NOT NULL - ) - SELECT l.pool_id, l.asset_a, l.asset_b, - l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp - FROM ids - CROSS JOIN LATERAL ( - SELECT * FROM pool_snapshots s - WHERE s.network = $1 AND s.pool_id = ids.pool_id - ORDER BY s.timestamp DESC - LIMIT 1 - ) l - WHERE ids.pool_id IS NOT NULL`, - [network] - ) - return { pools: result.rows } - }) - // GET /price/:assetA/:assetB/depth?amount=1000 app.get<{ Params: { assetA: string; assetB: string } diff --git a/src/app.ts b/src/app.ts index c0d2a117..67967e61 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { registerGraphQL } from './api/graphql' import { registerWebhookRoutes } from './routes/webhooks' import { registerCandleRoutes } from './routes/candles' import { registerPairsRoutes } from './routes/pairs' +import { registerPoolsRoutes } from './routes/pools' import { registerScreenerRoutes } from './routes/screener' import { registerHistoryRoutes } from './api/history' import { registerX402 } from './middleware/x402' @@ -128,6 +129,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise + additionalProperties: false + } +} + +const networkParam = { + type: 'string', + enum: ['testnet', 'mainnet'], + description: `Stellar network to read. Defaults to this deployment's active network (${activeNetwork}).`, +} + +const pairsCursorParam = { + type: 'string', + description: + 'Opaque cursor from the nextCursor of a previous page. Omit it for the first page; pass nextCursor back as cursor while hasMore is true.', +} + +export function lensToolDefinitions(): LensToolDefinition[] { + return [ + { + name: 'lens_list_pairs', + description: + 'List the trading pairs Lens watches on one Stellar network, each with its latest price and last update time. ' + + 'This is the discovery tool: call it first to find out which pairs exist and which asset codes and issuers are ' + + 'quotable — every asset is returned as {code, issuer} with the issuer\'s full key, never shortened. ' + + 'Results are a bounded page; while hasMore is true, pass nextCursor as cursor to get the next page. ' + + 'Use lens_screen_pairs instead when you want pairs ranked or filtered by volume, liquidity, 24h change or price, ' + + 'and lens_get_pools when you need the AMM liquidity behind a pair.', + inputSchema: { + type: 'object', + properties: { + network: networkParam, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_PAGE_SIZE, + default: PAIRS_PAGE_SIZE, + description: `Page size, 1 to ${MAX_PAGE_SIZE}. Defaults to ${PAIRS_PAGE_SIZE}.`, + }, + cursor: pairsCursorParam, + }, + additionalProperties: false, + }, + }, + { + name: 'lens_get_pools', + description: + 'List the AMM liquidity pools Lens indexes on one Stellar network — each pool\'s reserves, spot price, fee and ' + + 'latest snapshot time — to see the liquidity behind a pair or the whole market. ' + + 'Every pool carries the raw asset codes (asset_a/asset_b) and full asset ids (assetA/assetB) with the complete ' + + 'issuer key, so a pool\'s assets are never ambiguous. Results are a bounded page; while hasMore is true, pass ' + + 'nextCursor as cursor to get the next page. Use lens_list_pairs to discover pairs and lens_screen_pairs to rank ' + + 'them by activity.', + inputSchema: { + type: 'object', + properties: { + network: networkParam, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_PAGE_SIZE, + default: POOLS_PAGE_SIZE, + description: `Page size, 1 to ${MAX_PAGE_SIZE}. Defaults to ${POOLS_PAGE_SIZE}.`, + }, + cursor: pairsCursorParam, + }, + additionalProperties: false, + }, + }, + { + name: 'lens_screen_pairs', + description: + 'Screen and rank the watched pairs by 24h volume, price change, liquidity or price — the "what is moving" view, ' + + 'sorted and filtered server-side. Use this instead of lens_list_pairs when you want the top or filtered pairs ' + + 'rather than the full catalogue, and lens_get_pools for the reserves behind a specific pair. ' + + 'Results are a bounded page; while hasMore is true, pass nextCursor as cursor to get the next page. ' + + 'market_cap is not supported — use liquidity instead.', + inputSchema: { + type: 'object', + properties: { + network: networkParam, + volume: { type: 'number', description: 'Minimum 24h volume.' }, + change_24h_min: { type: 'number', description: 'Minimum 24h price change in percent.' }, + change_24h_max: { type: 'number', description: 'Maximum 24h price change in percent.' }, + price_min: { type: 'number', description: 'Minimum price.' }, + price_max: { type: 'number', description: 'Maximum price.' }, + liquidity: { type: 'number', description: 'Minimum liquidity.' }, + sortBy: { + type: 'string', + enum: ['volume', 'change_24h', 'price', 'liquidity'], + description: 'Field to sort by. Defaults to volume.', + }, + order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction. Defaults to desc.' }, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_PAGE_SIZE, + description: `Page size, 1 to ${MAX_PAGE_SIZE}. Defaults to 20.`, + }, + cursor: pairsCursorParam, + }, + additionalProperties: false, + }, + }, + ] +} + +export type LensToolName = 'lens_list_pairs' | 'lens_get_pools' | 'lens_screen_pairs' + +export function isLensToolName(name: string): name is LensToolName { + return name === 'lens_list_pairs' || name === 'lens_get_pools' || name === 'lens_screen_pairs' +} + +// ── Argument parsing ────────────────────────────────────────────────────────── + +type ToolArguments = Record | undefined + +function parseNetworkArg(args: ToolArguments): { network: NetworkName } | { error: string } { + const raw = args?.network + if (raw === undefined) return { network: activeNetwork } + if (raw === 'testnet' || raw === 'mainnet') return { network: raw } + return { error: 'network must be one of: testnet, mainnet' } +} + +function parseLimitArg(raw: unknown, fallback: number): { limit: number } | { error: string } { + if (raw === undefined) return { limit: fallback } + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) { + return { error: `limit must be an integer between 1 and ${MAX_PAGE_SIZE}` } + } + return { limit: Math.min(raw, MAX_PAGE_SIZE) } +} + +function parseCursorArg(raw: unknown): { cursor?: string } | { error: string } { + if (raw === undefined) return {} + if (typeof raw !== 'string' || raw.length === 0) return { error: 'cursor must be a non-empty string' } + return { cursor: raw } +} + +// The screener implementation parses string values (it shares them with the +// REST querystring), so tool numbers cross over as strings and the clamping +// and validation stay identical on both surfaces. +function stringifyScreenerArgs(args: ToolArguments): Record { + const out: Record = {} + for (const key of [ + 'volume', + 'change_24h_min', + 'change_24h_max', + 'price_min', + 'price_max', + 'liquidity', + 'sortBy', + 'order', + 'limit', + 'cursor', + ]) { + const raw = args?.[key] + if (raw !== undefined) out[key] = String(raw) + } + return out +} + +// ── Response helpers ────────────────────────────────────────────────────────── + +// A type alias (not an interface) so it carries the implicit index signature +// the MCP SDK's ServerResult accepts. +type McpToolResponse = { + content: { type: 'text'; text: string }[] + isError?: true +} + +function textResponse(payload: unknown): McpToolResponse { + return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] } +} + +function errorResponse(code: McpErrorCode, reason: string): McpToolResponse { + return { + content: [{ type: 'text', text: JSON.stringify({ isError: true, code, reason }) }], + isError: true, + } +} + +// ── Pools: resolve full asset ids for a pool row ────────────────────────────── + +/** + * pool_snapshots stores bare asset codes, but a code alone does not identify a + * Stellar asset — anyone can issue "USDC". The pool was snapshotted while its + * pair was watched, so the watched-pairs registry is where the issuer lives. + * A pool whose pair is no longer watched falls back to a null issuer rather + * than a guessed one. + */ +function resolvePoolAssets(row: PoolSnapshotRow): { assetA: AssetId; assetB: AssetId } { + const pair = getActivePairs().find( + p => + (p.assetA.code === row.asset_a && p.assetB.code === row.asset_b) || + (p.assetA.code === row.asset_b && p.assetB.code === row.asset_a) + ) + const assetA: AssetId = pair + ? pair.assetA.code === row.asset_a + ? pair.assetA + : pair.assetB + : { code: row.asset_a, issuer: null } + const assetB: AssetId = pair + ? pair.assetB.code === row.asset_b + ? pair.assetB + : pair.assetA + : { code: row.asset_b, issuer: null } + return { assetA, assetB } +} + +// ── Tool handlers ───────────────────────────────────────────────────────────── + +async function handleListPairs(args: ToolArguments): Promise { + const network = parseNetworkArg(args) + if ('error' in network) return errorResponse('ERR_BAD_REQUEST', network.error) + const limit = parseLimitArg(args?.limit, PAIRS_PAGE_SIZE) + if ('error' in limit) return errorResponse('ERR_BAD_REQUEST', limit.error) + const cursor = parseCursorArg(args?.cursor) + if ('error' in cursor) return errorResponse('ERR_BAD_REQUEST', cursor.error) + + const outcome = await listPairs(network.network, { limit: limit.limit, cursor: cursor.cursor }) + if (!outcome.ok) return errorResponse('ERR_BAD_REQUEST', outcome.error) + return textResponse({ + pairs: outcome.pairs, + nextCursor: outcome.nextCursor, + hasMore: outcome.hasMore, + }) +} + +async function handleGetPools(args: ToolArguments): Promise { + const network = parseNetworkArg(args) + if ('error' in network) return errorResponse('ERR_BAD_REQUEST', network.error) + const limit = parseLimitArg(args?.limit, POOLS_PAGE_SIZE) + if ('error' in limit) return errorResponse('ERR_BAD_REQUEST', limit.error) + const cursor = parseCursorArg(args?.cursor) + if ('error' in cursor) return errorResponse('ERR_BAD_REQUEST', cursor.error) + + const outcome = await listPools(network.network, { limit: limit.limit, cursor: cursor.cursor }) + if (!outcome.ok) return errorResponse('ERR_BAD_REQUEST', outcome.error) + return textResponse({ + pools: outcome.pools.map(pool => { + const { assetA, assetB } = resolvePoolAssets(pool) + return { ...pool, assetA, assetB } + }), + nextCursor: outcome.nextCursor, + hasMore: outcome.hasMore, + }) +} + +async function handleScreenPairs(args: ToolArguments): Promise { + const network = parseNetworkArg(args) + if ('error' in network) return errorResponse('ERR_BAD_REQUEST', network.error) + + const outcome = await screenPairs(network.network, stringifyScreenerArgs(args)) + if (!outcome.ok) return errorResponse('ERR_BAD_REQUEST', outcome.error) + return textResponse({ + data: outcome.data, + nextCursor: outcome.nextCursor, + hasMore: outcome.hasMore, + total: outcome.total, + }) +} + +/** + * Dispatches one of the Lens tools (guard with {@link isLensToolName} first). + * Failures inside the shared implementations are mapped to deterministic MCP + * error responses; the caller never has to catch. + */ +export async function callLensTool(name: LensToolName, args: ToolArguments): Promise { + try { + switch (name) { + case 'lens_list_pairs': + return await handleListPairs(args) + case 'lens_get_pools': + return await handleGetPools(args) + case 'lens_screen_pairs': + return await handleScreenPairs(args) + } + } catch (error) { + console.error(`[mcp] ${name} failed:`, error) + return errorResponse('ERR_INTERNAL_FAILURE', 'Internal server error') + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..34023cc9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 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 { callLensTool, isLensToolName, lensToolDefinitions } from './lensTools.js'; import { fetchWithTimeout } from '../fetch.js'; export interface McpServerConfig { @@ -68,10 +69,17 @@ export class BazaarMcpServer { required: ['url'], }, }, + // Lens's own data — pairs, pools and the screener, served from the same + // implementations as the REST routes (see ./lensTools.ts). + ...lensToolDefinitions(), ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (isLensToolName(request.params.name)) { + return callLensTool(request.params.name, request.params.arguments); + } + 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/src/routes/pairs.ts b/src/routes/pairs.ts index f2208e04..3fdbc039 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -1,61 +1,120 @@ import type { FastifyInstance } from 'fastify' import { getActivePairs, parseAssetStr, makePairKey, registerPair, persistPair, hasPair } from '../pairsRegistry' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' +import type { AssetId } from '../types' import '../middleware/network' // declares req.network on the FastifyRequest type -export async function registerPairsRoutes(app: FastifyInstance) { - // GET /pairs — list all active pairs with latest price metadata - // - // One index probe per watched pair on (network, pair_key, timestamp DESC): - // `ORDER BY timestamp DESC LIMIT 1` reads a single index entry, so the cost is - // O(pairs x log n) and no longer grows with the size of the price history. - // (This used to be DISTINCT ON over the whole table, every network included.) - // - // A pair that has gone quiet is deliberately NOT dropped and NOT bounded away: - // the probe still finds its last print, and the response carries that print's - // real `lastUpdated`, so a caller can see it is old. A pair with no rows on - // this network at all comes back with null price and timestamp. - app.get('/pairs', async (req) => { - const activePairs = getActivePairs() - const network = req.network ?? activeNetwork - - const result = await pgPool.query( - `SELECT p.pair_key, l.price, l.timestamp - FROM unnest($2::text[]) AS p(pair_key) - CROSS JOIN LATERAL ( - SELECT price, timestamp - FROM price_points - WHERE network = $1 - AND pair_key = p.pair_key - ORDER BY timestamp DESC - LIMIT 1 - ) l`, - [network, activePairs.map(p => p.pairKey)] - ) - - const latestPrices = new Map() - result.rows.forEach(row => { - latestPrices.set(row.pair_key, { - price: parseFloat(row.price), - timestamp: row.timestamp, - }) +export interface PairWithLatest { + pairKey: string + assetA: AssetId + assetB: AssetId + latestPrice: number | null + lastUpdated: Date | null +} + +export interface ListPairsPage { + /** Max pairs to return. Omit (REST /pairs) to return every active pair. */ + limit?: number + /** Opaque cursor from a previous `nextCursor`. */ + cursor?: string +} + +export type ListPairsOutcome = + | { ok: true; pairs: PairWithLatest[]; nextCursor: string | null; hasMore: boolean } + | { ok: false; error: string } + +// The registry array is the page order, so the cursor is just the index of the +// next pair, base64-encoded to keep it opaque like the screener's cursor. +function decodePairsCursor(cursor: string): number | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) + if (typeof parsed === 'number' && Number.isInteger(parsed) && parsed >= 0) return parsed + return null + } catch { + return null + } +} + +/** + * The pairs listing behind GET /pairs and the `lens_list_pairs` MCP tool. + * + * One index probe per watched pair on (network, pair_key, timestamp DESC): + * `ORDER BY timestamp DESC LIMIT 1` reads a single index entry, so the cost is + * O(pairs x log n) and no longer grows with the size of the price history. + * (This used to be DISTINCT ON over the whole table, every network included.) + * + * A pair that has gone quiet is deliberately NOT dropped and NOT bounded away: + * the probe still finds its last print, and the response carries that print's + * real `lastUpdated`, so a caller can see it is old. A pair with no rows on + * this network at all comes back with null price and timestamp. + * + * With `page.limit` the listing is bounded and comes with a `nextCursor` for + * the next page — the MCP tool needs that so an agent is not handed 500 pairs + * at once; without it (the REST route) every active pair is returned, as before. + */ +export async function listPairs(network: NetworkName, page: ListPairsPage = {}): Promise { + const activePairs = getActivePairs() + + let offset = 0 + if (page.cursor !== undefined) { + const decoded = decodePairsCursor(page.cursor) + if (decoded === null) return { ok: false, error: 'Invalid cursor format' } + offset = decoded + } + + const start = Math.min(offset, activePairs.length) + const end = page.limit !== undefined ? Math.min(start + page.limit, activePairs.length) : activePairs.length + const pagePairs = activePairs.slice(start, end) + + const result = await pgPool.query( + `SELECT p.pair_key, l.price, l.timestamp + FROM unnest($2::text[]) AS p(pair_key) + CROSS JOIN LATERAL ( + SELECT price, timestamp + FROM price_points + WHERE network = $1 + AND pair_key = p.pair_key + ORDER BY timestamp DESC + LIMIT 1 + ) l`, + [network, pagePairs.map(p => p.pairKey)] + ) + + const latestPrices = new Map() + result.rows.forEach(row => { + latestPrices.set(row.pair_key, { + price: parseFloat(row.price), + timestamp: row.timestamp, }) + }) + const pairs = pagePairs.map(p => { + const latest = latestPrices.get(p.pairKey) return { - pairs: activePairs.map(p => { - const latest = latestPrices.get(p.pairKey) - return { - pairKey: p.pairKey, - assetA: p.assetA, - assetB: p.assetB, - latestPrice: latest?.price ?? null, - lastUpdated: latest?.timestamp ?? null, - } - }) + pairKey: p.pairKey, + assetA: p.assetA, + assetB: p.assetB, + latestPrice: latest?.price ?? null, + lastUpdated: latest?.timestamp ?? null, } }) + const hasMore = end < activePairs.length + const nextCursor = hasMore ? Buffer.from(JSON.stringify(end)).toString('base64') : null + return { ok: true, pairs, nextCursor, hasMore } +} + +export async function registerPairsRoutes(app: FastifyInstance) { + // GET /pairs — list all active pairs with latest price metadata + app.get('/pairs', async (req, reply) => { + const outcome = await listPairs(req.network ?? activeNetwork) + // Unreachable today: the REST route sends no cursor, so listing cannot fail. + // Kept so a bad cursor is a 400 here too rather than a silent 500. + if (!outcome.ok) return reply.status(400).send({ error: outcome.error }) + return { pairs: outcome.pairs } + }) + // POST /pairs — add a new trading pair at runtime app.post<{ Body: { assetA?: string; assetB?: string } diff --git a/src/routes/pools.ts b/src/routes/pools.ts new file mode 100644 index 00000000..c61a5968 --- /dev/null +++ b/src/routes/pools.ts @@ -0,0 +1,132 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' +import { poolsResponseSchema } from '../api/schemas' +import '../middleware/network' // declares req.network on the FastifyRequest type + +export interface PoolSnapshotRow { + pool_id: string + asset_a: string + asset_b: string + reserve_a: number | null + reserve_b: number | null + spot_price: number | null + fee_bp: number | null + timestamp: Date +} + +export interface ListPoolsPage { + /** Max pools to return. Omit (REST /pools) to return every pool. */ + limit?: number + /** Opaque cursor from a previous `nextCursor`. */ + cursor?: string +} + +export type ListPoolsOutcome = + | { ok: true; pools: PoolSnapshotRow[]; nextCursor: string | null; hasMore: boolean } + | { ok: false; error: string } + +// The listing is ordered by pool_id, so the cursor is the last pool_id of the +// previous page, base64-encoded to keep it opaque like the screener's cursor. +function decodePoolsCursor(cursor: string): string | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) + if (typeof parsed === 'string' && parsed.length > 0) return parsed + return null + } catch { + return null + } +} + +/** + * The pools listing behind GET /pools and the `lens_get_pools` MCP tool. + * + * Latest snapshot per pool for one network, without reading history. The pool + * ids are discovered with a recursive "skip scan" over the + * (network, pool_id, timestamp DESC) index (jump to the next distinct pool_id + * each step), then one LIMIT 1 index probe per pool fetches its newest row. + * Cost is O(pools x log n); the old DISTINCT ON sorted every row of every + * network. A quiet pool is kept with its real `timestamp`, never dropped. + * + * With `page.cursor` the skip scan is seeded past that pool_id (a keyset page, + * still no history scan), and with `page.limit` the page is bounded — the MCP + * tool needs that so an agent is not handed every pool at once. Without either + * (the REST route) every pool on the network is returned, as before. + */ +export async function listPools(network: NetworkName, page: ListPoolsPage = {}): Promise { + let after: string | null = null + if (page.cursor !== undefined) { + const decoded = decodePoolsCursor(page.cursor) + if (decoded === null) return { ok: false, error: 'Invalid cursor format' } + after = decoded + } + + const params: unknown[] = [network] + let seed = '(SELECT pool_id FROM pool_snapshots WHERE network = $1' + if (after !== null) { + params.push(after) + seed += ` AND pool_id > $${params.length}` + } + seed += ' ORDER BY pool_id LIMIT 1)' + + let sql = `WITH RECURSIVE ids AS ( + ${seed} + UNION ALL + SELECT (SELECT s.pool_id FROM pool_snapshots s + WHERE s.network = $1 AND s.pool_id > ids.pool_id + ORDER BY s.pool_id LIMIT 1) + FROM ids + WHERE ids.pool_id IS NOT NULL + ) + SELECT l.pool_id, l.asset_a, l.asset_b, + l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp + FROM ids + CROSS JOIN LATERAL ( + SELECT * FROM pool_snapshots s + WHERE s.network = $1 AND s.pool_id = ids.pool_id + ORDER BY s.timestamp DESC + LIMIT 1 + ) l + WHERE ids.pool_id IS NOT NULL` + + if (page.limit !== undefined) { + // Fetch one extra to determine hasMore + params.push(page.limit + 1) + sql += ` + LIMIT $${params.length}` + + const result = await pgPool.query(sql, params) + const rows = result.rows as PoolSnapshotRow[] + const hasMore = rows.length > page.limit + const pools = hasMore ? rows.slice(0, page.limit) : rows + const nextCursor = + hasMore && pools.length > 0 + ? Buffer.from(JSON.stringify(pools[pools.length - 1].pool_id)).toString('base64') + : null + return { ok: true, pools, nextCursor, hasMore } + } + + const result = await pgPool.query(sql, params) + return { ok: true, pools: result.rows as PoolSnapshotRow[], nextCursor: null, hasMore: false } +} + +export async function registerPoolsRoutes(app: FastifyInstance) { + app.get( + '/pools', + { + schema: { + response: { + 200: poolsResponseSchema, + // Unreachable today (the REST route sends no cursor); declared so the + // bad-cursor branch stays a typed 400 rather than a silent 500. + 400: { type: 'object', required: ['error'], properties: { error: { type: 'string' } } }, + }, + }, + }, + async (req, reply) => { + const outcome = await listPools(req.network ?? activeNetwork) + if (!outcome.ok) return reply.status(400).send({ error: outcome.error }) + return { pools: outcome.pools } + } + ) +} diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 783059d4..dfca582c 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type // Lens has no circulating-supply data, so a market cap cannot be computed. It @@ -9,185 +9,211 @@ import '../middleware/network' // declares req.network on the FastifyRequest typ const MARKET_CAP_UNSUPPORTED = 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' -export async function registerScreenerRoutes(app: FastifyInstance) { - app.get<{ - Querystring: { - volume?: string - change_24h_min?: string - change_24h_max?: string - market_cap?: string // rejected with a 400, see MARKET_CAP_UNSUPPORTED - price_min?: string - price_max?: string - liquidity?: string - sortBy?: string - order?: string - cursor?: string - limit?: string - } - }>('/screener', async (req, reply) => { - try { - const q = req.query - - // 1. Parse and validate pagination & sorting - const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10), 1), 100) - if (isNaN(limit)) { - return reply.status(400).send({ error: 'limit must be a valid integer' }) - } - - if (q.market_cap !== undefined || q.sortBy === 'market_cap') { - return reply.status(400).send({ error: MARKET_CAP_UNSUPPORTED }) - } - - const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] - const sortBy = q.sortBy ?? 'volume' - if (!allowedSortFields.includes(sortBy)) { - return reply.status(400).send({ error: `sortBy must be one of: ${allowedSortFields.join(', ')}` }) - } - - const order = (q.order ?? 'desc').toLowerCase() - if (order !== 'asc' && order !== 'desc') { - return reply.status(400).send({ error: 'order must be asc or desc' }) - } - - // 2. Parse filters - const parseNum = (val?: string) => val ? parseFloat(val) : undefined - const filters = { - volume: parseNum(q.volume), - change_24h_min: parseNum(q.change_24h_min), - change_24h_max: parseNum(q.change_24h_max), - price_min: parseNum(q.price_min), - price_max: parseNum(q.price_max), - liquidity: parseNum(q.liquidity), - } - - for (const [key, val] of Object.entries(filters)) { - if (val !== undefined && isNaN(val)) { - return reply.status(400).send({ error: `Filter ${key} must be a valid number` }) - } - } - - // 3. Build query parameters and WHERE conditions - const conditions: string[] = [] - // Every CTE below is scoped to one network: testnet and mainnet rows share - // these tables, and pooling them would rank a pair on the other chain's data. - const params: any[] = [req.network ?? activeNetwork] - let paramIdx = 2 +export interface ScreenerQuery { + volume?: string + change_24h_min?: string + change_24h_max?: string + market_cap?: string // rejected, see MARKET_CAP_UNSUPPORTED + price_min?: string + price_max?: string + liquidity?: string + sortBy?: string + order?: string + cursor?: string + limit?: string +} - if (filters.volume !== undefined) { - conditions.push(`volume >= $${paramIdx++}`) - params.push(filters.volume) - } - if (filters.change_24h_min !== undefined) { - conditions.push(`change_24h >= $${paramIdx++}`) - params.push(filters.change_24h_min) - } - if (filters.change_24h_max !== undefined) { - conditions.push(`change_24h <= $${paramIdx++}`) - params.push(filters.change_24h_max) - } - if (filters.price_min !== undefined) { - conditions.push(`price >= $${paramIdx++}`) - params.push(filters.price_min) - } - if (filters.price_max !== undefined) { - conditions.push(`price <= $${paramIdx++}`) - params.push(filters.price_max) - } - if (filters.liquidity !== undefined) { - conditions.push(`liquidity >= $${paramIdx++}`) - params.push(filters.liquidity) - } +export interface ScreenerRow { + pair_key: string + volume: number + price: number + change_24h: number + liquidity: number +} - // 4. Cursor decoding and pagination condition - if (q.cursor) { - try { - const decoded = Buffer.from(q.cursor, 'base64').toString('utf-8') - const [lastVal, lastId] = JSON.parse(decoded) - - if (lastVal !== null && lastVal !== undefined && lastId) { - const op = order === 'desc' ? '<' : '>' - conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) - params.push(lastVal, lastId) - } - } catch (e) { - return reply.status(400).send({ error: 'Invalid cursor format' }) - } - } +export type ScreenPairsOutcome = + | { ok: true; data: ScreenerRow[]; nextCursor: string | null; hasMore: boolean; total: null } + | { ok: false; error: string } + +/** + * The screener behind GET /screener and the `lens_screen_pairs` MCP tool. + * + * Values arrive as strings so the REST querystring and the MCP tool arguments + * go through the exact same parsing and validation, and a page from one is + * byte-identical to a page from the other. Invalid input is reported as + * `{ ok: false, error }` (the route answers 400 with it); a database failure + * throws and the route answers 500. + */ +export async function screenPairs(network: NetworkName, q: ScreenerQuery): Promise { + // 1. Parse and validate pagination & sorting + const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10), 1), 100) + if (isNaN(limit)) { + return { ok: false, error: 'limit must be a valid integer' } + } + + if (q.market_cap !== undefined || q.sortBy === 'market_cap') { + return { ok: false, error: MARKET_CAP_UNSUPPORTED } + } + + const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] + const sortBy = q.sortBy ?? 'volume' + if (!allowedSortFields.includes(sortBy)) { + return { ok: false, error: `sortBy must be one of: ${allowedSortFields.join(', ')}` } + } + + const order = (q.order ?? 'desc').toLowerCase() + if (order !== 'asc' && order !== 'desc') { + return { ok: false, error: 'order must be asc or desc' } + } + + // 2. Parse filters + const parseNum = (val?: string) => val ? parseFloat(val) : undefined + const filters = { + volume: parseNum(q.volume), + change_24h_min: parseNum(q.change_24h_min), + change_24h_max: parseNum(q.change_24h_max), + price_min: parseNum(q.price_min), + price_max: parseNum(q.price_max), + liquidity: parseNum(q.liquidity), + } + + for (const [key, val] of Object.entries(filters)) { + if (val !== undefined && isNaN(val)) { + return { ok: false, error: `Filter ${key} must be a valid number` } + } + } + + // 3. Build query parameters and WHERE conditions + const conditions: string[] = [] + // Every CTE below is scoped to one network: testnet and mainnet rows share + // these tables, and pooling them would rank a pair on the other chain's data. + const params: any[] = [network] + let paramIdx = 2 + + if (filters.volume !== undefined) { + conditions.push(`volume >= $${paramIdx++}`) + params.push(filters.volume) + } + if (filters.change_24h_min !== undefined) { + conditions.push(`change_24h >= $${paramIdx++}`) + params.push(filters.change_24h_min) + } + if (filters.change_24h_max !== undefined) { + conditions.push(`change_24h <= $${paramIdx++}`) + params.push(filters.change_24h_max) + } + if (filters.price_min !== undefined) { + conditions.push(`price >= $${paramIdx++}`) + params.push(filters.price_min) + } + if (filters.price_max !== undefined) { + conditions.push(`price <= $${paramIdx++}`) + params.push(filters.price_max) + } + if (filters.liquidity !== undefined) { + conditions.push(`liquidity >= $${paramIdx++}`) + params.push(filters.liquidity) + } + + // 4. Cursor decoding and pagination condition + if (q.cursor) { + try { + const decoded = Buffer.from(q.cursor, 'base64').toString('utf-8') + const [lastVal, lastId] = JSON.parse(decoded) - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - - // 5. Construct the full SQL query using CTEs - const sql = ` - WITH pair_pools AS ( - SELECT DISTINCT pair_key, pool_id - FROM price_points - WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL - ), - latest_snapshots AS ( - SELECT DISTINCT ON (ps.pool_id) - ps.pool_id, - (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq - FROM pool_snapshots ps - WHERE ps.network = $1 - ORDER BY ps.pool_id, ps.timestamp DESC - ), - pair_liquidity AS ( - SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity - FROM pair_pools pp - JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id - GROUP BY pp.pair_key - ), - latest_aggregates AS ( - SELECT DISTINCT ON (pair_key) - pair_key, - COALESCE(volume::float, 0) AS volume, - COALESCE(close_price::float, 0) AS price, - CASE - WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 - ELSE 0 - END AS change_24h - FROM price_aggregates - WHERE network = $1 AND window = '24h' - ORDER BY pair_key, bucket DESC - ), - screener_data AS ( - SELECT - la.pair_key, - la.volume, - la.price, - la.change_24h, - COALESCE(pl.liquidity::float, 0) AS liquidity - FROM latest_aggregates la - LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key - ) - SELECT * FROM screener_data - ${whereClause} - ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} - LIMIT $${paramIdx} - ` - - // 6. Execute query - params.push(limit + 1) // Fetch one extra to determine hasMore - const result = await pgPool.query(sql, params) - const rows = result.rows - - const hasMore = rows.length > limit - const data = hasMore ? rows.slice(0, limit) : rows - - // 7. Generate nextCursor - let nextCursor = null - if (hasMore && data.length > 0) { - const lastRecord = data[data.length - 1] - const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) - nextCursor = Buffer.from(cursorPayload).toString('base64') + if (lastVal !== null && lastVal !== undefined && lastId) { + const op = order === 'desc' ? '<' : '>' + conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) + params.push(lastVal, lastId) } + } catch { + return { ok: false, error: 'Invalid cursor format' } + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + // 5. Construct the full SQL query using CTEs + const sql = ` + WITH pair_pools AS ( + SELECT DISTINCT pair_key, pool_id + FROM price_points + WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL + ), + latest_snapshots AS ( + SELECT DISTINCT ON (ps.pool_id) + ps.pool_id, + (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq + FROM pool_snapshots ps + WHERE ps.network = $1 + ORDER BY ps.pool_id, ps.timestamp DESC + ), + pair_liquidity AS ( + SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity + FROM pair_pools pp + JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id + GROUP BY pp.pair_key + ), + latest_aggregates AS ( + SELECT DISTINCT ON (pair_key) + pair_key, + COALESCE(volume::float, 0) AS volume, + COALESCE(close_price::float, 0) AS price, + CASE + WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 + ELSE 0 + END AS change_24h + FROM price_aggregates + WHERE network = $1 AND window = '24h' + ORDER BY pair_key, bucket DESC + ), + screener_data AS ( + SELECT + la.pair_key, + la.volume, + la.price, + la.change_24h, + COALESCE(pl.liquidity::float, 0) AS liquidity + FROM latest_aggregates la + LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key + ) + SELECT * FROM screener_data + ${whereClause} + ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} + LIMIT $${paramIdx} + ` + + // 6. Execute query + params.push(limit + 1) // Fetch one extra to determine hasMore + const result = await pgPool.query(sql, params) + const rows = result.rows + + const hasMore = rows.length > limit + const data = hasMore ? rows.slice(0, limit) : rows + + // 7. Generate nextCursor + let nextCursor = null + if (hasMore && data.length > 0) { + const lastRecord = data[data.length - 1] + const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) + nextCursor = Buffer.from(cursorPayload).toString('base64') + } + + return { + ok: true, + data, + nextCursor, + hasMore, + total: null, + } +} - return { - data, - nextCursor, - hasMore, - total: null, - } +export async function registerScreenerRoutes(app: FastifyInstance) { + app.get<{ Querystring: ScreenerQuery }>('/screener', async (req, reply) => { + try { + const outcome = await screenPairs(req.network ?? activeNetwork, req.query) + if (!outcome.ok) return reply.status(400).send({ error: outcome.error }) + return { data: outcome.data, nextCursor: outcome.nextCursor, hasMore: outcome.hasMore, total: outcome.total } } catch (error) { console.error('[screener]', error) return reply.status(500).send({ error: 'Internal server error' }) diff --git a/tests/mcp-lens.test.ts b/tests/mcp-lens.test.ts new file mode 100644 index 00000000..b643eb5b --- /dev/null +++ b/tests/mcp-lens.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import { BazaarMcpServer } from '../src/mcp/server' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { registerNetworkSelector } from '../src/middleware/network' +import { registerPairsRoutes } from '../src/routes/pairs' +import { registerPoolsRoutes } from '../src/routes/pools' +import { registerScreenerRoutes } from '../src/routes/screener' +import { activeNetwork } from '../src/config' + +// The lens tools share their implementations with the REST routes, so one +// mocked database and one mocked pairs registry answer both surfaces. +const { mockQuery, mockGetActivePairs } = vi.hoisted(() => ({ + mockQuery: vi.fn(), + mockGetActivePairs: vi.fn(), +})) + +vi.mock('../src/db', () => ({ + pgPool: { query: mockQuery }, + prisma: {}, +})) + +vi.mock('../src/pairsRegistry', () => ({ + getActivePairs: mockGetActivePairs, + hasPair: vi.fn().mockReturnValue(false), + registerPair: vi.fn(), + persistPair: vi.fn(), + parseAssetStr: vi.fn(), + makePairKey: vi.fn(), +})) + +const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +const XLM = { code: 'XLM', issuer: null } +const USDC = { code: 'USDC', issuer: USDC_ISSUER } +const OLD = new Date('2026-10-16T12:00:00.000Z') + +const WATCHED = [ + { pairKey: `USDC:${USDC_ISSUER}/XLM`, assetA: XLM, assetB: USDC }, +] + +function makePairs(count: number) { + return Array.from({ length: count }, (_, i) => { + const code = `A${String(i).padStart(3, '0')}` + const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + return { pairKey: `${code}:${issuer}/XLM`, assetA: XLM, assetB: { code, issuer } } + }) +} + +// ── Harnesses ───────────────────────────────────────────────────────────────── + +function buildServer() { + 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: async () => (await handlers.list()).tools, + callTool: async (name: string, args?: Record) => + handlers.call({ params: { name, arguments: args } }), + } +} + +async function callToolJson(server: ReturnType, name: string, args?: Record) { + const response = await server.callTool(name, args) + expect(response.isError, `tool ${name} errored: ${response.content[0].text}`).toBeFalsy() + return JSON.parse(response.content[0].text) +} + +async function buildRestApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerPairsRoutes(app) + await registerPoolsRoutes(app) + await registerScreenerRoutes(app) + await app.ready() + return app +} + +// Answers the pairs price probe with one print per queried pair key. +function mockLatestPrices() { + mockQuery.mockImplementation(async (_sql: string, params: unknown[]) => { + const keys = (params as string[][])?.[1] ?? [] + return { rows: keys.map(pair_key => ({ pair_key, price: '0.42', timestamp: OLD })) } + }) +} + +beforeEach(() => { + mockQuery.mockReset().mockResolvedValue({ rows: [] }) + mockGetActivePairs.mockReset().mockReturnValue([]) +}) + +// ── Tool surface ────────────────────────────────────────────────────────────── + +describe('lens MCP tools', () => { + it('lists the three lens tools next to the bazaar tools', async () => { + const server = buildServer() + const tools = await server.listTools() + const names = tools.map((t: { name: string }) => t.name) + expect(names).toEqual(expect.arrayContaining(['bazaar_search', 'paid_call'])) + expect(names).toEqual( + expect.arrayContaining(['lens_list_pairs', 'lens_get_pools', 'lens_screen_pairs']) + ) + }) + + it('describes each tool distinctly and documents the pagination', async () => { + const server = buildServer() + const tools = (await server.listTools()) as { name: string; description: string; inputSchema: any }[] + const byName = Object.fromEntries(tools.map(t => [t.name, t])) + + expect(byName.lens_list_pairs.description).toMatch(/discover/i) + expect(byName.lens_list_pairs.description).toMatch(/lens_screen_pairs/) + expect(byName.lens_list_pairs.description).toMatch(/nextCursor/) + + expect(byName.lens_get_pools.description).toMatch(/liquidity/i) + expect(byName.lens_get_pools.description).toMatch(/lens_list_pairs/) + expect(byName.lens_get_pools.description).toMatch(/nextCursor/) + + expect(byName.lens_screen_pairs.description).toMatch(/rank/i) + expect(byName.lens_screen_pairs.description).toMatch(/lens_get_pools/) + expect(byName.lens_screen_pairs.description).toMatch(/nextCursor/) + + for (const name of ['lens_list_pairs', 'lens_get_pools', 'lens_screen_pairs']) { + expect(byName[name].inputSchema.properties.limit.maximum).toBe(100) + expect(byName[name].inputSchema.properties.cursor).toBeTruthy() + } + }) +}) + +// ── lens_list_pairs ─────────────────────────────────────────────────────────── + +describe('lens_list_pairs vs GET /pairs', () => { + it('returns the same pairs page by page, issuers in full', async () => { + mockGetActivePairs.mockReturnValue(makePairs(3)) + mockLatestPrices() + const server = buildServer() + const app = await buildRestApp() + + const rest = (await app.inject({ method: 'GET', url: '/pairs' })).json() + expect(rest.pairs).toHaveLength(3) + + // Same implementation: the tool's probe is the route's probe. + const restSql = mockQuery.mock.calls[0][0] + + const page1 = await callToolJson(server, 'lens_list_pairs', { limit: 2 }) + expect(page1.pairs).toEqual(rest.pairs.slice(0, 2)) + expect(page1.hasMore).toBe(true) + expect(typeof page1.nextCursor).toBe('string') + expect(mockQuery.mock.calls.at(-1)![0]).toBe(restSql) + + const page2 = await callToolJson(server, 'lens_list_pairs', { limit: 2, cursor: page1.nextCursor }) + expect(page2.pairs).toEqual(rest.pairs.slice(2)) + expect(page2.hasMore).toBe(false) + expect(page2.nextCursor).toBe(null) + + // Issuers are complete keys, never shortened. + expect(page1.pairs[0].assetB.issuer).toMatch(/^G[A-Z2-7]{55}$/) + expect(page1.pairs[0].assetB.issuer).toHaveLength(56) + await app.close() + }) + + it('bounds the page by default and caps an oversized limit', async () => { + mockGetActivePairs.mockReturnValue(makePairs(60)) + mockLatestPrices() + const server = buildServer() + + const page = await callToolJson(server, 'lens_list_pairs', {}) + expect(page.pairs).toHaveLength(50) + expect(page.hasMore).toBe(true) + expect(page.nextCursor).toBeTruthy() + + const capped = await callToolJson(server, 'lens_list_pairs', { limit: 500 }) + expect(capped.pairs).toHaveLength(60) + expect(capped.pairs[0].pairKey).toMatch(/^A000:/) + }) + + it('honours the network argument the same way the route honours ?network=', async () => { + mockGetActivePairs.mockReturnValue(makePairs(1)) + mockLatestPrices() + const server = buildServer() + const app = await buildRestApp() + + await app.inject({ method: 'GET', url: '/pairs?network=mainnet' }) + expect(mockQuery.mock.calls[0][1][0]).toBe('mainnet') + + await callToolJson(server, 'lens_list_pairs', { network: 'mainnet' }) + expect(mockQuery.mock.calls.at(-1)![1][0]).toBe('mainnet') + await app.close() + }) +}) + +// ── lens_get_pools ──────────────────────────────────────────────────────────── + +describe('lens_get_pools vs GET /pools', () => { + const POOLS = [ + { pool_id: 'pool-a', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 10, reserve_b: 20, spot_price: 2, fee_bp: 30, timestamp: OLD }, + { pool_id: 'pool-b', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 11, reserve_b: 21, spot_price: 2, fee_bp: 30, timestamp: OLD }, + { pool_id: 'pool-c', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 12, reserve_b: 22, spot_price: 2, fee_bp: 30, timestamp: OLD }, + ] + + it('returns the same pools as the route, each asset with its full issuer', async () => { + mockGetActivePairs.mockReturnValue(WATCHED) + // The DB answers the keyset pages: everything for the REST route and page 1, + // then only what follows pool-b for page 2. + mockQuery + .mockResolvedValueOnce({ rows: POOLS }) + .mockResolvedValueOnce({ rows: POOLS }) + .mockResolvedValueOnce({ rows: POOLS.slice(2) }) + const server = buildServer() + const app = await buildRestApp() + + const rest = (await app.inject({ method: 'GET', url: '/pools' })).json() + expect(rest.pools).toHaveLength(3) + + const page1 = await callToolJson(server, 'lens_get_pools', { limit: 2 }) + expect(page1.pools).toHaveLength(2) + // Every REST field is present and equal, plus the resolved full asset ids. + expect(page1.pools[0]).toEqual({ + ...rest.pools[0], + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: USDC_ISSUER }, + }) + expect(page1.hasMore).toBe(true) + expect(typeof page1.nextCursor).toBe('string') + + // The cursor is a keyset on pool_id: the next page starts after pool-b. + const page2 = await callToolJson(server, 'lens_get_pools', { limit: 2, cursor: page1.nextCursor }) + const [sql, params] = mockQuery.mock.calls.at(-1)! + expect(sql).toMatch(/pool_id > \$2/) + expect(params).toEqual([activeNetwork, 'pool-b', 3]) + expect(page2.pools).toHaveLength(1) + expect(page2.pools[0].pool_id).toBe('pool-c') + expect(page2.hasMore).toBe(false) + await app.close() + }) + + it('falls back to a null issuer for a pool whose pair is no longer watched', async () => { + mockGetActivePairs.mockReturnValue(WATCHED) + mockQuery.mockResolvedValue({ + rows: [{ pool_id: 'pool-x', asset_a: 'FOO', asset_b: 'BAR', reserve_a: 1, reserve_b: 2, spot_price: 2, fee_bp: 30, timestamp: OLD }], + }) + const server = buildServer() + + const page = await callToolJson(server, 'lens_get_pools', {}) + expect(page.pools[0].assetA).toEqual({ code: 'FOO', issuer: null }) + expect(page.pools[0].assetB).toEqual({ code: 'BAR', issuer: null }) + }) + + it('bounds the page by default and caps an oversized limit', async () => { + mockGetActivePairs.mockReturnValue(WATCHED) + mockQuery.mockResolvedValue({ rows: POOLS }) + const server = buildServer() + + const page = await callToolJson(server, 'lens_get_pools', {}) + expect(page.pools).toHaveLength(3) + expect(page.hasMore).toBe(false) + + const capped = await callToolJson(server, 'lens_get_pools', { limit: 500 }) + // limit 500 is capped to 100, so the query asks for 101 rows. + expect(mockQuery.mock.calls.at(-1)![1].at(-1)).toBe(101) + }) +}) + +// ── lens_screen_pairs ───────────────────────────────────────────────────────── + +describe('lens_screen_pairs vs GET /screener', () => { + const ROWS = [ + { pair_key: `USDC:${USDC_ISSUER}/XLM`, volume: 30, price: 0.12, change_24h: 1.5, liquidity: 50000 }, + { pair_key: 'A000:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/XLM', volume: 20, price: 0.5, change_24h: -2, liquidity: 40000 }, + { pair_key: 'B001:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/XLM', volume: 10, price: 0.8, change_24h: 0, liquidity: 30000 }, + ] + + it('returns exactly the route body for the same filters, sort and page', async () => { + mockQuery.mockResolvedValue({ rows: ROWS }) + const server = buildServer() + const app = await buildRestApp() + + const rest = await app.inject({ + method: 'GET', + url: '/screener?volume=10&sortBy=liquidity&order=asc&limit=2', + }) + expect(rest.statusCode).toBe(200) + const restBody = rest.json() + + const toolBody = await callToolJson(server, 'lens_screen_pairs', { + volume: 10, + sortBy: 'liquidity', + order: 'asc', + limit: 2, + }) + expect(toolBody).toEqual(restBody) + + // Not just equal output: the identical SQL and bound parameters, because + // the tool runs the route's own implementation. + expect(mockQuery.mock.calls[1]).toEqual(mockQuery.mock.calls[0]) + + // pair_key carries the full issuer, never a shortened one. + expect(toolBody.data[0].pair_key).toContain(USDC_ISSUER) + await app.close() + }) + + it('pages with the same cursor as the route', async () => { + mockQuery + .mockResolvedValueOnce({ rows: ROWS }) + .mockResolvedValueOnce({ rows: ROWS.slice(2) }) + const server = buildServer() + + const page1 = await callToolJson(server, 'lens_screen_pairs', { limit: 2 }) + expect(page1.hasMore).toBe(true) + expect(page1.data).toHaveLength(2) + + const page2 = await callToolJson(server, 'lens_screen_pairs', { limit: 2, cursor: page1.nextCursor }) + expect(page2.data).toHaveLength(1) + expect(page2.hasMore).toBe(false) + expect(page2.nextCursor).toBe(null) + }) +}) + +// ── Error mapping ───────────────────────────────────────────────────────────── + +describe('lens tool errors', () => { + it('rejects an unknown network with ERR_BAD_REQUEST', async () => { + const server = buildServer() + const response = await server.callTool('lens_list_pairs', { network: 'devnet' }) + expect(response.isError).toBe(true) + expect(JSON.parse(response.content[0].text).code).toBe('ERR_BAD_REQUEST') + }) + + it('rejects a malformed cursor with ERR_BAD_REQUEST', async () => { + mockGetActivePairs.mockReturnValue(makePairs(1)) + mockLatestPrices() + const server = buildServer() + + for (const name of ['lens_list_pairs', 'lens_get_pools', 'lens_screen_pairs']) { + const response = await server.callTool(name, { cursor: 'not-base64-json' }) + expect(response.isError).toBe(true) + expect(JSON.parse(response.content[0].text).code).toBe('ERR_BAD_REQUEST') + } + }) + + it('rejects a non-numeric filter with the screener validation message', async () => { + const server = buildServer() + const response = await server.callTool('lens_screen_pairs', { volume: 'lots' }) + expect(response.isError).toBe(true) + const payload = JSON.parse(response.content[0].text) + expect(payload.code).toBe('ERR_BAD_REQUEST') + expect(payload.reason).toMatch(/volume must be a valid number/) + }) + + it('maps a database failure to ERR_INTERNAL_FAILURE without leaking it', async () => { + mockGetActivePairs.mockReturnValue(makePairs(1)) + mockQuery.mockRejectedValue(new Error('connection refused postgres://user:secret@host')) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const server = buildServer() + + const response = await server.callTool('lens_list_pairs', {}) + expect(response.isError).toBe(true) + const payload = JSON.parse(response.content[0].text) + expect(payload.code).toBe('ERR_INTERNAL_FAILURE') + expect(response.content[0].text).not.toMatch(/secret/) + spy.mockRestore() + }) +})