Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/mcp-lens-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'lens': minor
---

Add the `lens_list_pairs`, `lens_get_pools` and `lens_screen_pairs` MCP tools
(#409), so an agent that can already ask for a price can also ask what is
quotable, what liquidity stands behind it and what is moving — the three
questions REST answered and MCP did not, which left an agent guessing asset
codes. Each tool runs the function its REST route runs (`listPairs`, `listPools`
and the screener service behind `GET /screener` and the GraphQL query), so a
page over MCP is the same rows, filters and cursor as a page over HTTP. Pages
are bounded by the server's configured page limit and carry `nextCursor` and
`hasMore`; a page too large for the tool output limit is returned labelled as
truncated, without a cursor that would skip the rows it dropped. Every asset
carries its issuer in full, since a Stellar asset code alone identifies nothing,
and each description says which of the three questions it answers.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,11 @@ When multiple venues quote the same pair, their prices naturally diverge due to
| `paid_call` | Fetches an x402-gated URL and handles the `402 → sign → retry` loop. | **Yes — it signs with a real Stellar key** |
| `payment_budget` | Reports the configured limits and what is left of today's budget. | No |
| `lens_get_price` | Reads Lens's own aggregated price for a watched pair. | No |
| `lens_list_pairs` | Lists the pairs watched on one network, one bounded page at a time, each with its latest price and the issuer of both assets in full. | No |
| `lens_get_pools` | Lists AMM pools with their newest reserves, spot price and fee — how much liquidity stands behind a pair. | No |
| `lens_screen_pairs` | Ranks watched pairs by volume, 24h change, price or liquidity, with the same filters as `GET /screener`. | No |

`payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes.
`payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. The three data tools follow the same rule: they are advertised only where the host supplies Lens's data, and each one runs the very function its REST route runs, so a page over MCP is the same rows and cursor as a page over HTTP. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes.

**⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration,
spending warning, and worked example before running.
Expand Down
8 changes: 6 additions & 2 deletions docs/mcp-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
The Lens MCP server (`src/mcp/server.ts`) offers an agent two kinds of thing:

- **Tools** — functions it calls to *do* something: `bazaar_search`,
`paid_call`, `lens_get_price`.
`paid_call`, `lens_get_price`, and where Lens's own data is reachable the
three data tools `lens_list_pairs`, `lens_get_pools` and `lens_screen_pairs`.
- **Resources** — read-only content it attaches to its context once, instead of
re-fetching by calling a tool every turn.

Expand Down Expand Up @@ -62,7 +63,10 @@ for the same reason `createLensPriceQueries` lives in its own module: a process
that has not opted into Lens's own price data should not pay for importing `pg`
and Prisma just to list what the service watches. `lens://pairs` therefore
reports the *declared* pair list for the network, not the process-wide runtime
registry, matching what `lens_get_price` accepts.
registry, matching what `lens_get_price` accepts. The `lens_list_pairs` tool is
the other half of that: it runs `GET /pairs`'s own listing, including pairs
added at runtime, one bounded page with a cursor at a time — which is why the
resource stays a cheap, cacheable snapshot instead of a paginated read.

A host that does hold that state can inject its own descriptors, exactly as it
injects `priceQueries`:
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
POOLS_DEFAULT_LIMIT,
POOLS_MAX_LIMIT,
} from '../pagination'
import { registerRESTRoutes } from '../api/rest'
import { registerPoolsRoutes } from '../routes/pools'
import { registerPairsRoutes } from '../routes/pairs'

describe('parseLimit', () => {
Expand Down Expand Up @@ -111,7 +111,7 @@ describe('slicePage', () => {

async function buildPoolsApp() {
const app = Fastify({ logger: false })
await registerRESTRoutes(app)
await registerPoolsRoutes(app)
await app.ready()
return app
}
Expand Down
13 changes: 2 additions & 11 deletions src/__tests__/pools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -27,7 +18,7 @@ async function buildApp(network?: 'testnet' | 'mainnet') {
req.network = network
})
}
await registerRESTRoutes(app)
await registerPoolsRoutes(app)
await app.ready()
return app
}
Expand Down
100 changes: 3 additions & 97 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,12 @@ import {
priceResponseSchema,
routeResponseSchema,
historyResponseSchema,
poolsResponseSchema,
depthResponseSchema,
impactResponseSchema,
installResponseValidation,
} from './schemas'
import { findOverlappingGaps } from '../backfill/gaps'
import { getDepth, getImpact } from '../pricing/depth'
import {
POOLS_DEFAULT_LIMIT,
POOLS_MAX_LIMIT,
decodeCursor,
encodeCursor,
parseLimit,
slicePage,
} from '../pagination'

function makePairKey(a: string, b: string): string {
return [a, b].sort().join('/')
Expand Down Expand Up @@ -381,94 +372,9 @@ export async function registerRESTRoutes(app: FastifyInstance) {
}
)

// GET /pools?cursor=&limit=
//
// 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.
//
// Paginated keyset-style on pool_id ASC (default limit 50, max 200): the
// cursor carries the last page's pool_id, and the skip scan's seed query
// resumes strictly after it, so a pool inserted mid-paging cannot shift a
// page boundary the way an OFFSET would.
app.get<{ Querystring: { cursor?: string; limit?: string } }>(
'/pools',
{ config: { scope: 'read:prices' }, schema: { response: { 200: poolsResponseSchema } } },
async (req, reply) => {
const network = req.network ?? activeNetwork
const decimalFormat = resolveDecimalFormat(req)
if (decimalFormat === 'string') {
reply.header('x-decimal-format', 'string')
}

const parsedLimit = parseLimit(req.query.limit, POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT)
if ('error' in parsedLimit) {
return reply.status(400).send({ error: parsedLimit.error })
}
const { limit } = parsedLimit

const params: unknown[] = [network]
let seedFilter = ''
if (req.query.cursor) {
const decoded = decodeCursor(req.query.cursor)
if (decoded === null) {
return reply.status(400).send({ error: 'Invalid cursor format' })
}
if (decoded !== 'skip') {
params.push(decoded.value)
seedFilter = ` AND pool_id > $${params.length}`
}
}
params.push(limit + 1) // Fetch one extra to determine hasMore

const result = await pgPool.query(
`WITH RECURSIVE ids AS (
(SELECT pool_id FROM pool_snapshots WHERE network = $1${seedFilter} 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::numeric, l.reserve_b::numeric, l.spot_price::numeric, 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
ORDER BY l.pool_id ASC
LIMIT $${params.length}`,
params
)

const { data, hasMore } = slicePage(result.rows, limit)
const nextCursor =
hasMore && data.length > 0 ? encodeCursor(data[data.length - 1].pool_id, data[data.length - 1].pool_id) : null

return {
pools: data.map(r => ({
pool_id: r.pool_id,
asset_a: r.asset_a,
asset_b: r.asset_b,
reserve_a: formatDecimal(r.reserve_a, decimalFormat),
reserve_b: formatDecimal(r.reserve_b, decimalFormat),
spot_price: formatDecimal(r.spot_price, decimalFormat),
fee_bp: r.fee_bp !== null && r.fee_bp !== undefined ? parseInt(String(r.fee_bp), 10) : null,
timestamp: r.timestamp,
})),
nextCursor,
hasMore,
}
}
)
// GET /pools?cursor=&limit= lives in src/routes/pools.ts, next to /pairs and
// /screener, so the `lens_get_pools` MCP tool runs the same listPools this
// route does; registerPoolsRoutes() is called from app.ts.

// GET /price/:assetA/:assetB/depth?amount=1000
app.get<{
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 { registerMoversRoutes } from './routes/movers'
import { registerHistoryRoutes } from './api/history'
Expand Down Expand Up @@ -240,6 +241,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await registerWebhookRoutes(app)
await registerCandleRoutes(app)
await registerPairsRoutes(app)
await registerPoolsRoutes(app)
await registerScreenerRoutes(app)
await registerMoversRoutes(app)
await registerHistoryRoutes(app)
Expand Down
82 changes: 82 additions & 0 deletions src/mcp/lensDataQueries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getActivePairs } from '../pairsRegistry'
import { activeNetwork, getNetworkConfig } from '../config'
import type { NetworkName } from '../config'
import { listPairs } from '../routes/pairs'
import { listPools } from '../routes/pools'
import {
MARKET_CAP_UNSUPPORTED,
SCREENER_DEFAULT_LIMIT,
SCREENER_MAX_LIMIT,
runScreener,
ScreenerCursorError,
ScreenerSortError,
} from '../services/screener'
import type { LensDataQueries } from './lensTools.js'

/**
* The data layer behind the three Lens data tools (#409), built from the same
* functions the REST routes call: `listPairs` serves `GET /pairs`, `listPools`
* serves `GET /pools` and `runScreener` serves both `GET /screener` and the
* GraphQL `screener` query. Asking through MCP is therefore a different
* transport, not a different answer.
*
* Like {@link createLensPriceQueries}, this lives in its own module so the tool
* logic stays testable with a stub and only a process that opts into serving
* Lens's own data imports `pg`.
*/
export function createLensDataQueries(): LensDataQueries {
return {
activeNetwork,

// On this process's own network the live registry is the truth: a pair added
// through POST /pairs is watched from that moment, and REST lists it. For any
// other network the registry says nothing — its entries were registered
// against whichever network this process runs — so that network's declared
// pairs are used instead, rather than answering a mainnet question with the
// pairs a testnet process happens to watch.
listPairs(network, page) {
return listPairs(network, page, pairsFor(network))
},

listPools(network, page) {
return listPools(network, page)
},

watchedPairs(network) {
return pairsFor(network)
},

async screen(request) {
if (request.marketCapAsked) {
return { ok: false, error: MARKET_CAP_UNSUPPORTED }
}
// The screener's own ceiling is tighter than the MCP page ceiling, so the
// page size has to be settled here and reported back: echoing the ask would
// tell the agent it got a page the service never fetched. Same formula
// runScreener applies, kept next to the call that runs it.
const limit = Math.min(SCREENER_MAX_LIMIT, Math.max(1, request.limit ?? SCREENER_DEFAULT_LIMIT))
try {
const page = await runScreener({
network: request.network,
filters: request.filters,
sortBy: request.sortBy,
order: request.order,
cursor: request.cursor ?? null,
limit,
})
return { ok: true, page, limit }
} catch (e) {
// Both are refusals the REST route also answers with a 4xx; anything
// else is the database being unreachable and stays a throw.
if (e instanceof ScreenerCursorError || e instanceof ScreenerSortError) {
return { ok: false, error: e.message }
}
throw e
}
},
}
}

function pairsFor(network: NetworkName) {
return network === activeNetwork ? getActivePairs() : getNetworkConfig(network).pairs
}
Loading
Loading