From e8c0f6f916d1d7d1c6c0a989f26131c17dcc5451 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 14:27:27 +0530 Subject: [PATCH] feat(mcp): add lens_get_price tool for aggregated stellar prices (#408) --- .changeset/mcp-lens-get-price.md | 10 + src/mcp/server.ts | 166 +++++++++++++ tests/mcp.test.ts | 408 +++++++++++++++++++++++-------- 3 files changed, 484 insertions(+), 100 deletions(-) create mode 100644 .changeset/mcp-lens-get-price.md 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/server.ts b/src/mcp/server.ts index 3f69c5b1..aaa9d241 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,11 +3,27 @@ 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 { fetchWithTimeout } from '../fetch.js'; +import { activeNetwork, getNetworkConfig, type NetworkName } from '../config.js'; +import { getCachedPrice } from '../redis.js'; +import { getAggregatedPrice } from '../aggregator/vwap.js'; +import { getBestRoute } from '../aggregator/bestRoute.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; } +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; @@ -68,6 +84,33 @@ export class BazaarMcpServer { required: ['url'], }, }, + { + 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'], + }, + }, ], })); @@ -134,6 +177,129 @@ 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_BAD_REQUEST', + `Pair ${rawAssetA}/${rawAssetB} is not tracked 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, + 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 8b979ad6..7924288a 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,123 +1,331 @@ -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 }); - - // 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' + 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', + }); + }); + + 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 }); + 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 }); + 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 }); - - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + 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_MISSING_TRUSTLINE'); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); - }); + 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 }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + 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' + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); }); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); - - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + 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 }); + const { callTool } = getHandlers(server); + + 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); }); + }); + + 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() }); + const { listTools } = getHandlers(server); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + 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']); + }); }); - - 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" }) + + 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() }); + 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.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'); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); - - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + + it('should accept either asset order and native alias', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + // Reversed order and 'native' for XLM + 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`); + expect(data.price).toBe(0.125); + }); + + it('should reject unknown pair with structured error', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'XLM', + assetB: 'BTC', + network: 'mainnet' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_BAD_REQUEST'); + expect(content.reason).toContain('is not tracked on mainnet'); + }); + + it('should NOT silently match when issuer mismatches (anti-silent-match rule)', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + // Requesting with an incorrect issuer for USDC on mainnet + const response = await callTool({ + params: { + name: 'lens_get_price', + arguments: { + assetA: 'XLM', + assetB: `USDC:${OTHER_ISSUER}`, + network: 'mainnet' + } + } + }); + + // Must refuse with error, NOT silently quote mainnet USDC! + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_BAD_REQUEST'); + expect(content.reason).toContain('is not tracked on mainnet'); + }); + + it('should report staleness accurately so agent can distinguish fresh from old price', async () => { + mocks.getAggregatedPrice.mockResolvedValueOnce({ + price: 0.119, + sources: 1, + confidence: 'low', + lastTradeAgeSeconds: 450, // > 300s -> stale + stale: true, + volume24h: 1000, + sdexPrice: 0.119, + ammPrice: 0, + }); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + 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.stale).toBe(true); + expect(data.lastTradeAgeSeconds).toBe(450); + expect(data.confidence).toBe('low'); }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('should reject missing or malformed asset arguments with clear error message', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + // Missing assetA + 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"'); + + // Malformed issuer on assetB + 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'); + }); }); });