Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/mcp-lens-get-price.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions examples/mcp-server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
6 changes: 6 additions & 0 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(', ')}`
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/mcp/priceQueries.ts
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
Loading
Loading