diff --git a/.changeset/mcp-lens-get-price.md b/.changeset/mcp-lens-get-price.md new file mode 100644 index 00000000..2e4c72de --- /dev/null +++ b/.changeset/mcp-lens-get-price.md @@ -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) diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index c9a76967..60373d2b 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -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; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..55109fa2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 { @@ -23,6 +26,18 @@ export interface McpServerConfig { bazaarFetch?: Partial; } +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; @@ -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'], + }, + }, ], })); @@ -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}`); }); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..e36f44dd 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,124 +1,319 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const mocks = vi.hoisted(() => ({ + getCachedPrice: vi.fn(), + getAggregatedPrice: vi.fn(), + getBestRoute: vi.fn(), +})); + +vi.mock('../src/redis', () => ({ + getCachedPrice: mocks.getCachedPrice, +})); + +vi.mock('../src/aggregator/vwap', () => ({ + getAggregatedPrice: mocks.getAggregatedPrice, +})); + +vi.mock('../src/aggregator/bestRoute', () => ({ + getBestRoute: mocks.getBestRoute, +})); + import { BazaarMcpServer } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const OTHER_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +function getHandlers(server: BazaarMcpServer) { + let callHandler: any = null; + let listHandler: any = null; + + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + callHandler = fn; + } + if (schema === ListToolsRequestSchema) { + listHandler = fn; + } + }; + + (server as any).setupHandlers(); + return { + callTool: (req: any) => callHandler(req), + listTools: () => listHandler(), + }; +} describe('BazaarMcpServer', () => { - it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCachedPrice.mockResolvedValue(null); + mocks.getAggregatedPrice.mockResolvedValue({ + price: 0.125, + sources: 3, + confidence: 'high', + lastTradeAgeSeconds: 12, + stale: false, + volume24h: 125000, + sdexPrice: 0.125, + ammPrice: 0.1249, + }); + mocks.getBestRoute.mockResolvedValue({ + route: 'SDEX', + sdexPrice: 0.125, + ammPrice: 0.1249, + recommendation: 'SDEX provides best rate', + }); + }); - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - - // Re-setup handlers to capture the handler - (server as any).setupHandlers(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + describe('paid_call error mapping and baseline tests', () => { + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } } - } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); - }); + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); - it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + }); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => 'Payment Required' + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } } - } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); }); + + it('should return successfully on a successful call', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: 'hello' }) + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBeFalsy(); + const parsedText = JSON.parse(response.content[0].text); + expect(parsedText.success).toBe(true); + }); }); - it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - text: async () => 'Payment Required' + describe('ListTools schema validation including lens_get_price (Issue #408)', () => { + it('should advertise lens_get_price tool with strict inputSchema', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { listTools } = getHandlers(server); + + const result = await listTools(); + const priceTool = result.tools.find((t: any) => t.name === 'lens_get_price'); + expect(priceTool).toBeDefined(); + expect(priceTool.description).toContain('aggregated market price'); + expect(priceTool.description).toContain('CODE:ISSUER'); + + const schema = priceTool.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.required).toContain('assetA'); + expect(schema.required).toContain('assetB'); + expect(schema.properties.assetA).toBeDefined(); + expect(schema.properties.assetB).toBeDefined(); + expect(schema.properties.network).toBeDefined(); + expect(schema.properties.network.enum).toEqual(['testnet', 'mainnet']); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + describe('lens_get_price price resolution & issuer matching (Issue #408)', () => { + it('should return aggregated price for a known pair with structured content', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const response = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'XLM', + assetB: `USDC:${MAINNET_USDC}`, + network: 'mainnet' + } } - } + }); + + expect(response.isError).toBeFalsy(); + const data = JSON.parse(response.content[0].text); + expect(data.pairKey).toBe(`USDC:${MAINNET_USDC}/XLM`); + expect(data.price).toBe(0.125); + expect(data.sources).toBe(3); + expect(data.confidence).toBe('high'); + expect(data.lastTradeAgeSeconds).toBe(12); + expect(data.stale).toBe(false); + expect(data.bestRoute).toBe('SDEX'); + expect(data.network).toBe('mainnet'); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); - }); - - it('should return successfully on a successful call', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) + it('should accept either asset order and native alias', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: `USDC:${MAINNET_USDC}`, + assetB: 'native', + network: 'mainnet' + } + } + }); + + expect(response.isError).toBeFalsy(); + const data = JSON.parse(response.content[0].text); + expect(data.pairKey).toBe(`USDC:${MAINNET_USDC}/XLM`); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + it('should return cached price directly when present with X-Cache HIT', async () => { + mocks.getCachedPrice.mockResolvedValueOnce( + JSON.stringify({ + price: 0.1245, + sources: 2, + confidence: 'medium', + lastTradeAgeSeconds: 5, + stale: false, + volume24h: 90000, + bestRoute: 'AMM' + }) + ); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'XLM', + assetB: `USDC:${MAINNET_USDC}`, + network: 'mainnet' + } } - } + }); + + expect(response.isError).toBeFalsy(); + const data = JSON.parse(response.content[0].text); + expect(data.price).toBe(0.1245); + expect(data.cached).toBe(true); + expect(mocks.getAggregatedPrice).not.toHaveBeenCalled(); }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('should return ERR_PAIR_NOT_WATCHED when pair is unknown or issuer does not match', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const wrongIssuerRes = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'XLM', + assetB: `USDC:${OTHER_ISSUER}`, + network: 'mainnet' + } + } + }); + + expect(wrongIssuerRes.isError).toBe(true); + const err = JSON.parse(wrongIssuerRes.content[0].text); + expect(err.code).toBe('ERR_PAIR_NOT_WATCHED'); + expect(err.reason).toContain('not watched on mainnet'); + + const unknownPairRes = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'BTC', + assetB: 'ETH', + network: 'mainnet' + } + } + }); + expect(unknownPairRes.isError).toBe(true); + expect(JSON.parse(unknownPairRes.content[0].text).code).toBe('ERR_PAIR_NOT_WATCHED'); + }); + + it('should fail with ERR_INVALID_ARGUMENT on empty or malformed asset identifiers', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn(), paidCallAllowlist: ['example.com'] }); + const { callTool } = getHandlers(server); + + const missingA = await callTool({ + params: { + name: 'lens_get_price', + arguments: { assetB: 'XLM' } + } + }); + expect(missingA.isError).toBe(true); + expect(JSON.parse(missingA.content[0].text).reason).toContain('"assetA"'); + + const malformedB = await callTool({ + params: { + name: 'lens_get_price', + arguments: { assetA: 'XLM', assetB: 'USDC:not-a-valid-issuer' } + } + }); + expect(malformedB.isError).toBe(true); + expect(JSON.parse(malformedB.content[0].text).reason).toContain('Invalid assetB'); + }); }); });