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
10 changes: 10 additions & 0 deletions .changeset/mcp-lens-get-price.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'lens': minor
---

Add `lens_get_price` tool to MCP server for agents to query aggregated Stellar prices:
- Returns composite price across SDEX, AMM, Soroswap and Aquarius with sources count, confidence, last-trade age, staleness signal, and best execution route (resolves #408)
- Supports native XLM and issued assets with strict issuer matching, preventing silent mismatches on same-named codes (resolves #408)
- Allows selectable network (`testnet` or `mainnet`) defaulting to configured active network (resolves #408)
- Returns structured JSON payload so AI agents can consume numerical rates directly without prose parsing (resolves #408)
- Comprehensive test coverage for known pairs, reversed asset order, native alias, unknown pairs, issuer mismatches, and staleness detection (resolves #408)
1 change: 1 addition & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const MCP_ERROR_CODES = {
// paid_call allow-list — #487
ERR_URL_BLOCKED: 'ERR_URL_BLOCKED',
ERR_HOST_NOT_ALLOWED: 'ERR_HOST_NOT_ALLOWED',
ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED',
ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE'
} as const;

Expand Down
168 changes: 167 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { MCP_ERROR_CODES, McpErrorCode } from './codes.js';
import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js';
import { fetchWithTimeout } from '../fetch.js';
import { config } from '../config.js';
import { config, activeNetwork, getNetworkConfig, type NetworkName } from '../config.js';
import { getCachedPrice } from '../redis.js';
import { getAggregatedPrice } from '../aggregator/vwap.js';
import { getBestRoute } from '../aggregator/bestRoute.js';
import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js';

export interface McpServerConfig {
Expand All @@ -23,6 +26,18 @@ export interface McpServerConfig {
bazaarFetch?: Partial<SsrfFetchOptions>;
}

export function parseAssetIdentifier(str: string): { code: string; issuer: string | null } | null {
const trimmed = str.trim();
if (trimmed.toLowerCase() === 'native') return { code: 'XLM', issuer: null };
const parts = trimmed.split(':');
if (parts.length < 1 || parts.length > 2) return null;
const code = parts[0].trim().toUpperCase();
if (!code || code.length > 12) return null;
const issuer = parts[1] && parts[1].toLowerCase() !== 'native' ? parts[1].trim() : null;
if (issuer && !/^G[A-Z2-7]{55}$/.test(issuer)) return null;
return { code, issuer };
}

export class BazaarMcpServer {
private server: Server;
private fetchWithPayment: typeof fetch;
Expand Down Expand Up @@ -107,6 +122,33 @@ export class BazaarMcpServer {
additionalProperties: false,
},
},
{
name: 'lens_get_price',
description:
'Get the current aggregated market price for a Stellar asset pair across SDEX, AMM, Soroswap and Aquarius. ' +
'Returns composite price, liquidity sources count, calculation confidence, last-trade age, staleness signal, and best execution route. ' +
'Assets can be specified as bare code (e.g. XLM) or with issuer as CODE:ISSUER (e.g. USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN). ' +
'Network is selectable (testnet or mainnet) and defaults to the configured active network.',
inputSchema: {
type: 'object',
properties: {
assetA: {
type: 'string',
description: 'Base asset code (e.g. XLM) or CODE:ISSUER (e.g. USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN)',
},
assetB: {
type: 'string',
description: 'Counter asset code (e.g. XLM) or CODE:ISSUER (e.g. USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN)',
},
network: {
type: 'string',
enum: ['testnet', 'mainnet'],
description: "Stellar network ('testnet' or 'mainnet'; defaults to configured active network)",
},
},
required: ['assetA', 'assetB'],
},
},
],
}));

Expand Down Expand Up @@ -183,6 +225,130 @@ export class BazaarMcpServer {
}
}

if (request.params.name === 'lens_get_price') {
const rawAssetA = request.params.arguments?.assetA;
const rawAssetB = request.params.arguments?.assetB;

if (!rawAssetA || typeof rawAssetA !== 'string' || !rawAssetA.trim()) {
return this.createErrorResponse('ERR_BAD_REQUEST', 'Missing or invalid argument "assetA": assetA is required');
}

if (!rawAssetB || typeof rawAssetB !== 'string' || !rawAssetB.trim()) {
return this.createErrorResponse('ERR_BAD_REQUEST', 'Missing or invalid argument "assetB": assetB is required');
}

const parsedA = parseAssetIdentifier(rawAssetA);
if (!parsedA) {
return this.createErrorResponse(
'ERR_BAD_REQUEST',
`Invalid assetA: expected CODE or CODE:ISSUER, got "${rawAssetA}"`
);
}

const parsedB = parseAssetIdentifier(rawAssetB);
if (!parsedB) {
return this.createErrorResponse(
'ERR_BAD_REQUEST',
`Invalid assetB: expected CODE or CODE:ISSUER, got "${rawAssetB}"`
);
}

const rawNetwork = request.params.arguments?.network;
if (rawNetwork !== undefined && rawNetwork !== 'testnet' && rawNetwork !== 'mainnet') {
return this.createErrorResponse(
'ERR_BAD_REQUEST',
`Invalid network: expected 'testnet' or 'mainnet', got "${rawNetwork}"`
);
}

const network: NetworkName = (rawNetwork as NetworkName) || activeNetwork;

try {
const pairs = getNetworkConfig(network).pairs;

const matches = (
q: { code: string; issuer: string | null },
side: { code: string; issuer?: string | null }
) => {
if (q.code !== side.code.toUpperCase()) return false;
if (q.issuer && side.issuer) return q.issuer === side.issuer;
if (q.issuer && !side.issuer) return false;
return true;
};

const pair = pairs.find(
(p) =>
(matches(parsedA, p.assetA) && matches(parsedB, p.assetB)) ||
(matches(parsedA, p.assetB) && matches(parsedB, p.assetA))
);

if (!pair) {
return this.createErrorResponse(
'ERR_PAIR_NOT_WATCHED',
`Pair ${rawAssetA}/${rawAssetB} is not watched on ${network}`
);
}

const cacheKey = `${network}:${pair.pairKey}`;
let cached: any = null;
try {
const cachedStr = await getCachedPrice(cacheKey);
if (cachedStr) {
cached = JSON.parse(cachedStr);
}
} catch {
// cache miss
}

const agg =
cached && typeof cached.price === 'number'
? cached
: await getAggregatedPrice(pair.pairKey, network);

let bestRoute = 'UNKNOWN';
try {
const routeInfo = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, 1000, network);
bestRoute = routeInfo.route;
} catch {
// best route fallback
}

const stale =
agg.stale !== undefined
? agg.stale
: agg.lastTradeAgeSeconds !== null
? agg.lastTradeAgeSeconds >= 300
: true;

const payload = {
pairKey: pair.pairKey,
assetA: rawAssetA,
assetB: rawAssetB,
network,
price: agg.price ?? 0,
sources: agg.sources ?? 0,
confidence: agg.confidence ?? 'unknown',
lastTradeAgeSeconds: agg.lastTradeAgeSeconds ?? null,
stale,
bestRoute,
volume24h: agg.volume24h ?? 0,
sdexPrice: agg.sdexPrice ?? 0,
ammPrice: agg.ammPrice ?? 0,
cached: Boolean(cached),
lastUpdated: new Date().toISOString(),
};

return {
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
};
} catch (e: any) {
return this.createErrorResponse(
'ERR_INTERNAL_FAILURE',
e.message || 'An error occurred while fetching the price.'
);
}
}

throw new Error(`Unknown tool: ${request.params.name}`);
});
}
Expand Down
Loading