From 40b10ec9755d34ef1aaf989a9d6f45f4a812eab1 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 16:37:02 +0530 Subject: [PATCH] feat(mcp): add pairs/pools/screener tools, upstream timeout guard, and docs (#409, #349, #422) --- .changeset/mcp-market-tools-and-docs.md | 8 + .env.example | 32 ++ README.md | 2 + examples/mcp-server/README.md | 158 ++++++ examples/mcp-server/run.ts | 86 +++- src/__tests__/envDrift.test.ts | 28 +- src/api/rest.ts | 53 +- src/mcp/codes.ts | 5 +- src/mcp/server.ts | 656 +++++++++++++++++++++--- src/metrics.ts | 8 + src/routes/pairs.ts | 79 +-- src/routes/screener.ts | 348 +++++++------ tests/mcp.test.ts | 518 +++++++++++++++---- 13 files changed, 1570 insertions(+), 411 deletions(-) create mode 100644 .changeset/mcp-market-tools-and-docs.md create mode 100644 examples/mcp-server/README.md diff --git a/.changeset/mcp-market-tools-and-docs.md b/.changeset/mcp-market-tools-and-docs.md new file mode 100644 index 00000000..82ae3bb3 --- /dev/null +++ b/.changeset/mcp-market-tools-and-docs.md @@ -0,0 +1,8 @@ +--- +'lens': minor +--- + +Expand MCP server with market discovery tools, upstream timeout/format validation, and documentation: +- Expose `lens_list_pairs`, `lens_get_pools`, and `lens_screen_pairs` MCP tools sharing implementation with corresponding REST routes (`/pairs`, `/pools`, `/screener`) with full asset issuer fidelity and pagination bounding (#409). +- Enforce outbound deadline via `AbortSignal.timeout` on upstream calls in `bazaar_search`, rejecting non-2xx and non-JSON payloads prior to parsing and surfacing structured error descriptions naming the upstream endpoint (#349). +- Add comprehensive documentation and worked client agent example in `examples/mcp-server/README.md`, prominently highlighting mainnet spending safeguards, opt-in procedures, and linking from root `README.md` (#422). diff --git a/.env.example b/.env.example index 41e4465f..b2ca1631 100644 --- a/.env.example +++ b/.env.example @@ -153,6 +153,11 @@ ORACLE_RELAY_ASSET_A=XLM ORACLE_RELAY_ASSET_B=USDC # How often the relay polls Lens and updates the contract. ORACLE_RELAY_INTERVAL_MS=60000 +# Soroban RPC URL for the oracle contract calls (default: https://soroban-testnet.stellar.org) +ORACLE_RELAY_RPC_URL=https://soroban-testnet.stellar.org +# Stellar network passphrase for the oracle relay (default: testnet passphrase) +ORACLE_RELAY_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + # --- Price Alert Bot Example --- # Lens WebSocket endpoint the alert bot connects to. @@ -191,3 +196,30 @@ FACILITATOR_FEE_STROOPS_MAINNET=50000 # against a table that has never been pruned cannot hold a long lock or blow # the WAL. Default 1000. SNAPSHOT_PRUNE_BATCH_SIZE=1000 + +# ─── Model Context Protocol (MCP) Server ─────────────────────────────────── +# Stellar secret key used by the MCP agent to sign testnet payments for the paid_call tool. +# Leave empty to run the MCP server in read-only mode on testnet. +MCP_AGENT_SECRET_KEY_TESTNET= + +# Stellar secret key used by the MCP agent to sign mainnet payments for the paid_call tool. +# WARNING: Setting this variable allows the MCP agent to spend real funds from this account! +# Mainnet execution is strictly opt-in: setting this variable alone will NOT activate +# mainnet unless MCP_ENABLE_MAINNET=true is also explicitly set. +MCP_AGENT_SECRET_KEY_MAINNET= + +# Explicit opt-in flag required to activate stellar:pubnet signing with MCP_AGENT_SECRET_KEY_MAINNET. +# When set to true, logs a clear warning at startup that live funds can be spent. Default: false. +MCP_ENABLE_MAINNET=false + +# Maximum price the MCP agent is permitted to pay for a single paid_call (default: $1.00). +MCP_MAX_PAYMENT_PRICE=$1.00 + +# Maximum number of concurrent in-flight MCP tool calls before throttling (default: 5). +MCP_MAX_CONCURRENCY=5 + +# Per-tool rate limits in requests per minute for MCP server calls. +# Exceeding these limits returns a structured ERR_RATE_LIMITED error with a retry hint. +MCP_RATE_LIMIT_PAID_CALL=10 +MCP_RATE_LIMIT_BAZAAR_SEARCH=30 + diff --git a/README.md b/README.md index 8351617e..20676598 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity | POST | `/webhooks` | Subscribe to a price-threshold webhook | | DELETE | `/webhooks/:id` | Delete a webhook subscription | | GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) | +| stdio | MCP Server | Model Context Protocol server for AI agents (`bazaar_search`, `paid_call`, `lens_list_pairs`, `lens_get_pools`, `lens_screen_pairs`, docs: [`examples/mcp-server/README.md`](examples/mcp-server/README.md)) | + Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml); `tests/openapi.test.ts` boots the server and fails if a registered route is diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md new file mode 100644 index 00000000..dc036694 --- /dev/null +++ b/examples/mcp-server/README.md @@ -0,0 +1,158 @@ +# Lens Model Context Protocol (MCP) Server & Agent Example + +This directory contains the reference Model Context Protocol (MCP) server implementation (`run.ts`) and client agent demonstration (`agent.ts`) for Lens. + +The MCP server exposes Lens's unified price discovery, liquidity pool metrics, pair screening, and x402 payment execution capabilities directly to AI agents via the standard [Model Context Protocol](https://modelcontextprotocol.io/). + +--- + +## ⚠️ CRITICAL SPENDING WARNING & MAINNET SAFEGUARDS + +> **IMPORTANT:** The `paid_call` tool signs and submits real Stellar transactions on behalf of the agent to settle x402 payment challenges. +> +> When configured with `MCP_AGENT_SECRET_KEY_MAINNET` and `MCP_ENABLE_MAINNET=true`, **THE AGENT HAS ACCESS TO AND CAN SPEND REAL FUNDS FROM THAT ACCOUNT.** +> +> An AI agent can enter a loop or execute rapid requests. To prevent accidental loss of capital: +> 1. **Always test on testnet first** using `MCP_AGENT_SECRET_KEY_TESTNET`. +> 2. **Mainnet is strictly opt-in:** setting `MCP_AGENT_SECRET_KEY_MAINNET` alone will **not** enable mainnet signing unless `MCP_ENABLE_MAINNET=true` is also explicitly set. +> 3. The server starts safely in **read-only mode** if no keys are provided. You can inspect tools, discover pairs, query liquidity pools, and read resources without risking funds. +> 4. Keep your `MCP_MAX_PAYMENT_PRICE` ceiling low (default is `$1.00`). +> 5. **Never commit, log, or share real secret keys.** + +--- + +## Commands & Execution + +### 1. Start the MCP Server (`npm run mcp:server`) +Starts the Bazaar MCP server on standard I/O (`stdio`): + +```bash +npm run mcp:server +``` + +Configuration is loaded from your environment (`.env`). The server automatically logs which schemes are configured without ever logging secret keys. + +### 2. Run the Agent Example (`npm run mcp:agent`) +Spawns a mock Bazaar provider and boots an agent that connects to the MCP server via `stdio`, searches for resources, and completes a paid request loop: + +```bash +npm run mcp:agent +``` + +--- + +## Configuration (`.env`) + +Configure the following environment variables in your `.env` file: + +| Variable | Required | Default | Description | +| :--- | :---: | :---: | :--- | +| `MCP_AGENT_SECRET_KEY_TESTNET` | Optional | `""` | Stellar secret key used to sign payments on `stellar:testnet`. When empty, testnet paid calls refuse with `ERR_MISSING_KEY`. | +| `MCP_AGENT_SECRET_KEY_MAINNET` | Optional | `""` | Stellar secret key used to sign payments on `stellar:pubnet`. **Spends real funds!** | +| `MCP_ENABLE_MAINNET` | Optional | `false` | Explicit opt-in flag required to activate `stellar:pubnet` signing. Defaults to `false`. | +| `MCP_MAX_PAYMENT_PRICE` | Optional | `"$1.00"` | Maximum price limit per paid tool call. Requests exceeding this price fail closed. | +| `MCP_MAX_CONCURRENCY` | Optional | `5` | Maximum number of in-flight concurrent tool executions before throttling with `ERR_CONCURRENCY_LIMIT_EXCEEDED`. | +| `MCP_RATE_LIMIT_PAID_CALL` | Optional | `10` | Rate limit for `paid_call` in requests per minute. Exceeding returns `ERR_RATE_LIMITED` with retry hint. | +| `MCP_RATE_LIMIT_BAZAAR_SEARCH` | Optional | `30` | Rate limit for `bazaar_search` in requests per minute. | + +--- + +## Available MCP Tools + +The server advertises the following tools to connected agents: + +1. **`bazaar_search`** + - **Purpose:** Search for discovery endpoints and API services across Stellar Bazaar nodes. + - **When to use:** Use this to find available endpoints matching an asset query before making payments. + +2. **`paid_call`** + - **Purpose:** Execute HTTP calls to x402-gated resources, automatically handling 402 challenge negotiation, transaction signing, and settlement replay. + - **When to use:** When accessing paid data feeds requiring micro-payment authorization. + +3. **`lens_list_pairs`** + - **Purpose:** List active trading pairs watched by Lens on Stellar, including base and quote asset codes, full issuer addresses, and latest price metadata. + - **When to use:** Discover available markets before requesting prices or order book depth. Results are bounded and paginated (`limit`, `offset`). + +4. **`lens_get_pools`** + - **Purpose:** Inspect liquidity pool reserves, spot prices, fee basis points, and token reserves across AMM and Soroswap pools. + - **When to use:** Analyze underlying liquidity pool depth and reserve ratios for a pair. + +5. **`lens_screen_pairs`** + - **Purpose:** Screen and rank trading pairs on Stellar by 24-hour volume, price changes, or liquidity pool depth. + - **When to use:** Identify market movers, highest-volume pairs, or filter pairs by minimum liquidity. + +--- + +## Available MCP Resources + +In addition to tools, the server advertises read-only resources: + +- **`lens://pairs`** (`application/json`): Active watched pairs with full issuer addresses and latest price timestamps. +- **`lens://venues`** (`application/json`): Supported execution venues (SDEX orderbook, Stellar classic AMM, Soroswap, Aquarius). +- **`lens://openapi`** (`application/json`): Complete OpenAPI 3.0 specification for the Lens REST API. +- **`lens://status`** (`application/json`): Health check and service status. + +--- + +## Worked Client Connection Example + +Below is a complete, runnable client example using `@modelcontextprotocol/sdk`: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'node:path'; + +async function runClient() { + // 1. Connect transport to the local MCP server process + const transport = new StdioClientTransport({ + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['tsx', path.resolve(__dirname, 'run.ts')], + }); + + const client = new Client( + { name: 'lens-example-client', version: '1.0.0' }, + { capabilities: {} } + ); + + await client.connect(transport); + console.log('Connected to Lens MCP server.'); + + // 2. Discover available tools + const tools = await client.listTools(); + console.log('Available tools:', tools.tools.map((t) => t.name)); + + // 3. Discover available resources + const resources = await client.listResources(); + console.log('Available resources:', resources.resources.map((r) => r.uri)); + + // 4. Read watched pairs resource + const pairsResource = await client.readResource({ uri: 'lens://pairs' }); + console.log('Watched pairs:', pairsResource.contents[0].text); + + // 5. Discover markets using lens_list_pairs tool + const pairsResult = await client.callTool({ + name: 'lens_list_pairs', + arguments: { limit: 5, offset: 0, network: 'testnet' }, + }); + console.log('Market pairs:', pairsResult.content[0].text); + + // 6. Inspect liquidity pools using lens_get_pools tool + const poolsResult = await client.callTool({ + name: 'lens_get_pools', + arguments: { limit: 5, offset: 0, network: 'testnet' }, + }); + console.log('Pools:', poolsResult.content[0].text); + + // 7. Screen active pairs by volume + const screenResult = await client.callTool({ + name: 'lens_screen_pairs', + arguments: { sortBy: 'volume', order: 'desc', limit: 5, network: 'testnet' }, + }); + console.log('Screener results:', screenResult.content[0].text); + + await client.close(); +} + +runClient().catch(console.error); +``` diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 2b971525..d14a7fa2 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -7,43 +7,79 @@ import { createEd25519Signer } from '@x402/stellar'; async function main() { const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; - - if (!testnetKey || !mainnetKey) { - console.error('[mcp-server] Error: MCP_AGENT_SECRET_KEY_TESTNET and MCP_AGENT_SECRET_KEY_MAINNET must be set'); - process.exit(1); + const enableMainnet = process.env.MCP_ENABLE_MAINNET === 'true' || process.env.MCP_ENABLE_MAINNET === '1'; + + const schemes: Array<{ network: string; client: any }> = []; + const configuredNetworks: string[] = []; + + if (testnetKey) { + const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); + const testnetClient = new ExactStellarScheme(testnetSigner); + schemes.push({ + network: 'stellar:testnet', + client: testnetClient, + }); + configuredNetworks.push('stellar:testnet'); } - const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); - const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); - - const testnetClient = new ExactStellarScheme(testnetSigner); - const pubnetClient = new ExactStellarScheme(pubnetSigner); - - const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { - maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', - schemes: [ - { - network: 'stellar:testnet', - client: testnetClient, - }, - { + if (mainnetKey) { + if (enableMainnet) { + console.error('[mcp-server] WARNING: stellar:pubnet is enabled with live funds!'); + const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); + const pubnetClient = new ExactStellarScheme(pubnetSigner); + schemes.push({ network: 'stellar:pubnet', client: pubnetClient, - } - ] - }); + }); + configuredNetworks.push('stellar:pubnet'); + } else { + console.error( + '[mcp-server] WARNING: MCP_AGENT_SECRET_KEY_MAINNET is set, but mainnet is opt-in. Set MCP_ENABLE_MAINNET=true to enable stellar:pubnet.' + ); + } + } + + if (configuredNetworks.length === 0) { + console.error('[mcp-server] WARNING: No payment keys configured. Server is running in read-only mode.'); + } + + const fetchWithPayment = + schemes.length > 0 + ? wrapFetchWithPaymentFromConfig(globalThis.fetch, { + maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', + schemes, + }) + : undefined; const server = new BazaarMcpServer({ - fetchWithPayment + fetchWithPayment, + configuredNetworks, + maxConcurrency: process.env.MCP_MAX_CONCURRENCY ? parseInt(process.env.MCP_MAX_CONCURRENCY, 10) : undefined, + rateLimits: { + paid_call: { + requestsPerMinute: process.env.MCP_RATE_LIMIT_PAID_CALL + ? parseInt(process.env.MCP_RATE_LIMIT_PAID_CALL, 10) + : 10, + }, + bazaar_search: { + requestsPerMinute: process.env.MCP_RATE_LIMIT_BAZAAR_SEARCH + ? parseInt(process.env.MCP_RATE_LIMIT_BAZAAR_SEARCH, 10) + : 30, + }, + }, }); 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] Agent configured for ${ + configuredNetworks.length > 0 ? configuredNetworks.join(' and ') : 'read-only (no payment keys)' + }` + ); + await server.run(); } -main().catch(err => { +main().catch((err) => { console.error('[mcp-server] Fatal error:', err); process.exit(1); }); diff --git a/src/__tests__/envDrift.test.ts b/src/__tests__/envDrift.test.ts index 2187798b..f8f907af 100644 --- a/src/__tests__/envDrift.test.ts +++ b/src/__tests__/envDrift.test.ts @@ -5,8 +5,11 @@ import ts from 'typescript' const rootDir = resolve(__dirname, '../..') const srcDir = resolve(rootDir, 'src') +const examplesDir = resolve(rootDir, 'examples') +const scriptsDir = resolve(rootDir, 'scripts') const envExamplePath = resolve(rootDir, '.env.example') + /** * Explicit allow-list for environment variables that are deliberately * undocumented in .env.example. @@ -156,7 +159,11 @@ describe('.env.example drift guard', () => { // vitest's 5s default and makes this guard flaky. Give it real headroom. it('asserts each process.env read in source appears in .env.example or the allow-list', { timeout: 60_000 }, () => { const documentedVars = parseEnvExample(envExamplePath) - const sourceFiles = getSourceFiles(srcDir) + const sourceFiles = [ + ...getSourceFiles(srcDir), + ...getSourceFiles(examplesDir), + ...getSourceFiles(scriptsDir), + ] const allReads: EnvRead[] = [] for (const file of sourceFiles) { @@ -204,6 +211,25 @@ describe('.env.example drift guard', () => { expect(missing[0].name).toBe('SOME_BRAND_NEW_SECRET_VAR') }) + it('fails when an un-documented process.env read is encountered in examples/', () => { + const sampleCode = ` + export function runExample() { + const secret = process.env.MCP_SOME_NEW_UNDOCUMENTED_KEY + return secret + } + ` + const reads = extractEnvReadsFromSource(sampleCode, 'examples/mcp-server/sample.ts') + const documentedVars = parseEnvExample(envExamplePath) + + const missing = reads.filter( + (read) => !documentedVars.has(read.name) && !(read.name in UNDOCUMENTED_ENV_ALLOW_LIST) + ) + + expect(missing).toHaveLength(1) + expect(missing[0].name).toBe('MCP_SOME_NEW_UNDOCUMENTED_KEY') + }) + + it('requires every allow-list entry to have an explicit rationale', () => { const entries = Object.entries(UNDOCUMENTED_ENV_ALLOW_LIST) expect(entries.length).toBeGreaterThan(0) diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..503ef0f0 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -62,6 +62,33 @@ function findPair(assetA: string, assetB: string, network: NetworkName) { ) } +export async function getPoolsData(network: NetworkName = activeNetwork) { + const result = await pgPool.query( + `WITH RECURSIVE ids AS ( + (SELECT pool_id FROM pool_snapshots WHERE network = $1 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::float, l.reserve_b::float, l.spot_price::float, 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`, + [network] + ) + return result.rows +} + + export async function registerRESTRoutes(app: FastifyInstance) { // Validate every response against its declared schema in dev/test (no-op in // production). Must run before the routes below are registered so they pick @@ -214,31 +241,11 @@ export async function registerRESTRoutes(app: FastifyInstance) { // network. A quiet pool is kept with its real `timestamp`, never dropped. app.get('/pools', { schema: { response: { 200: poolsResponseSchema } } }, async (req) => { const network = req.network ?? activeNetwork - const result = await pgPool.query( - `WITH RECURSIVE ids AS ( - (SELECT pool_id FROM pool_snapshots WHERE network = $1 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::float, l.reserve_b::float, l.spot_price::float, 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`, - [network] - ) - return { pools: result.rows } + const pools = await getPoolsData(network) + return { pools } }) + // GET /price/:assetA/:assetB/depth?amount=1000 app.get<{ Params: { assetA: string; assetB: string } diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..6caf4aaf 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,7 +6,10 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', - ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' + ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE', + ERR_MISSING_KEY: 'ERR_MISSING_KEY', + ERR_RATE_LIMITED: 'ERR_RATE_LIMITED', + ERR_CONCURRENCY_LIMIT_EXCEEDED: 'ERR_CONCURRENCY_LIMIT_EXCEEDED' } as const; export type McpErrorCode = keyof typeof MCP_ERROR_CODES; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..58413702 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,19 +1,58 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; +import { mcp_rate_limit_rejections_total } from '../metrics.js'; +import { config, activeNetwork, NetworkName } from '../config.js'; +import { getPairsData } from '../routes/pairs.js'; +import { getPoolsData } from '../api/rest.js'; +import { getScreenerData, ScreenerValidationError } from '../routes/screener.js'; +import { readFileSync, statSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ToolRateLimitConfig { + requestsPerMinute: number; +} export interface McpServerConfig { - fetchWithPayment: typeof fetch; + fetchWithPayment?: typeof fetch; + configuredNetworks?: string[]; + maxConcurrency?: number; + rateLimits?: Record; + openapiPath?: string; } export class BazaarMcpServer { private server: Server; - private fetchWithPayment: typeof fetch; + private fetchWithPayment?: typeof fetch; + private configuredNetworks: string[]; + private maxConcurrency: number; + private inFlightCalls: number = 0; + private rateLimits: Record; + private callTimestamps: Map = new Map(); + private openapiPath?: string; + + constructor(configObj: McpServerConfig = {}) { + this.fetchWithPayment = configObj.fetchWithPayment; + this.configuredNetworks = + configObj.configuredNetworks ?? (configObj.fetchWithPayment ? ['stellar:testnet', 'stellar:pubnet'] : []); + this.maxConcurrency = configObj.maxConcurrency ?? 5; + this.rateLimits = { + paid_call: { requestsPerMinute: 10 }, + bazaar_search: { requestsPerMinute: 30 }, + lens_list_pairs: { requestsPerMinute: 60 }, + lens_get_pools: { requestsPerMinute: 60 }, + lens_screen_pairs: { requestsPerMinute: 60 }, + ...(configObj.rateLimits ?? {}), + }; + this.openapiPath = configObj.openapiPath; - constructor(config: McpServerConfig) { - this.fetchWithPayment = config.fetchWithPayment; this.server = new Server( { name: 'bazaar-mcp-server', @@ -22,6 +61,7 @@ export class BazaarMcpServer { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -29,6 +69,49 @@ export class BazaarMcpServer { this.setupHandlers(); } + private checkRateLimit(tool: string): { + allowed: boolean; + retryAfterSeconds?: number; + reason?: string; + code?: McpErrorCode; + } { + // 1. Concurrency limit check + if (this.inFlightCalls >= this.maxConcurrency) { + mcp_rate_limit_rejections_total.inc({ tool, reason: 'concurrency' }); + return { + allowed: false, + code: 'ERR_CONCURRENCY_LIMIT_EXCEEDED', + retryAfterSeconds: 1, + reason: `Concurrency limit of ${this.maxConcurrency} in-flight tool calls reached. Retry after 1 second.`, + }; + } + + // 2. Per-tool rate limit check (sliding window) + const limitCfg = this.rateLimits[tool] ?? { requestsPerMinute: 60 }; + const maxReqs = limitCfg.requestsPerMinute; + const windowMs = 60_000; + const now = Date.now(); + + const timestamps = this.callTimestamps.get(tool) ?? []; + const recent = timestamps.filter((t) => now - t < windowMs); + this.callTimestamps.set(tool, recent); + + if (recent.length >= maxReqs) { + const oldest = recent[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)); + mcp_rate_limit_rejections_total.inc({ tool, reason: 'rate_limit' }); + return { + allowed: false, + code: 'ERR_RATE_LIMITED', + retryAfterSeconds, + reason: `Rate limit for tool '${tool}' exceeded (${maxReqs} req/min). Retry after ${retryAfterSeconds} seconds.`, + }; + } + + recent.push(now); + return { allowed: true }; + } + private setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ @@ -68,104 +151,541 @@ export class BazaarMcpServer { required: ['url'], }, }, + { + name: 'lens_list_pairs', + description: + 'List active trading pairs watched by Lens on Stellar, including base and quote asset codes, full issuer addresses, and latest price metadata. Use this to discover available markets before requesting prices or depth.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + description: 'Maximum number of pairs to return (1-100, default: 20)', + }, + offset: { + type: 'integer', + description: 'Pagination offset (0-based, default: 0)', + }, + network: { + type: 'string', + description: "Stellar network ('testnet' or 'mainnet', default: server active network)", + }, + }, + }, + }, + { + name: 'lens_get_pools', + description: + 'Inspect liquidity pool reserves, spot prices, fee basis points, and token reserves across AMM and Soroswap pools. Use this when analyzing pool depth and reserve ratios.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + description: 'Maximum number of pools to return (1-100, default: 20)', + }, + offset: { + type: 'integer', + description: 'Pagination offset (0-based, default: 0)', + }, + network: { + type: 'string', + description: "Stellar network ('testnet' or 'mainnet', default: server active network)", + }, + }, + }, + }, + { + name: 'lens_screen_pairs', + description: + 'Screen and rank trading pairs on Stellar by 24-hour volume, price changes, or liquidity pool depth. Use this to find active market movers or filter pairs by minimum volume or liquidity.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + description: 'Maximum number of pairs to return (1-100, default: 20)', + }, + cursor: { + type: 'string', + description: 'Opaque pagination cursor from a previous screen_pairs response', + }, + sortBy: { + type: 'string', + enum: ['volume', 'change_24h', 'price', 'liquidity'], + description: "Field to sort by (default: 'volume')", + }, + order: { + type: 'string', + enum: ['asc', 'desc'], + description: "Sort direction (default: 'desc')", + }, + volume: { + type: 'number', + description: 'Minimum 24h trading volume filter', + }, + liquidity: { + type: 'number', + description: 'Minimum pool liquidity filter', + }, + change_24h_min: { + type: 'number', + description: 'Minimum 24h price percentage change filter', + }, + change_24h_max: { + type: 'number', + description: 'Maximum 24h price percentage change filter', + }, + network: { + type: 'string', + description: "Stellar network ('testnet' or 'mainnet', default: server active network)", + }, + }, + }, + }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'bazaar_search') { - const url = String(request.params.arguments?.url); - const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; - - try { - const endpoint = query + const toolName = request.params.name; + + const rateCheck = this.checkRateLimit(toolName); + if (!rateCheck.allowed) { + return this.createErrorResponse(rateCheck.code!, rateCheck.reason!, { + retryAfterSeconds: rateCheck.retryAfterSeconds, + }); + } + + this.inFlightCalls++; + try { + if (toolName === 'bazaar_search') { + const url = String(request.params.arguments?.url); + const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; + + const endpoint = query ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` : `${url.replace(/\/$/, '')}/discovery/resources`; - - const res = await fetchWithTimeout(endpoint); - - if (!res.ok) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); - } - - const data = await res.json(); - - const hasResources = (d: unknown): d is { resources: unknown[] } => - typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); - - if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { - return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); + + try { + // Deadline via AbortSignal.timeout in fetchWithTimeout + const res = await fetchWithTimeout(endpoint, { + timeoutMs: 10_000, + }); + + if (!res.ok) { + return this.createErrorResponse( + 'ERR_SERVICE_UNREACHABLE', + `Upstream service at ${endpoint} failed with HTTP status ${res.status}` + ); + } + + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return this.createErrorResponse( + 'ERR_SERVICE_UNREACHABLE', + `Upstream service at ${endpoint} returned non-JSON content-type: ${contentType || 'none'}` + ); + } + + let data: unknown; + try { + data = await res.json(); + } catch (err: any) { + return this.createErrorResponse( + 'ERR_SERVICE_UNREACHABLE', + `Upstream service at ${endpoint} returned invalid JSON: ${err.message}` + ); + } + + const hasResources = (d: unknown): d is { resources: unknown[] } => + typeof d === 'object' && + d !== null && + 'resources' in d && + Array.isArray((d as Record).resources); + + if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { + return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); + } + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; + } catch (e: any) { + const isTimeout = + e.name === 'TimeoutError' || + e.name === 'AbortError' || + String(e.message).toLowerCase().includes('timeout') || + String(e.message).toLowerCase().includes('aborted'); + + const reason = isTimeout + ? `Request to upstream service at ${endpoint} timed out after deadline` + : `Failed to reach upstream service at ${endpoint}: ${e.message}`; + + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', reason); } - - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; - } catch (e: any) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); } - } - if (request.params.name === 'paid_call') { - const url = String(request.params.arguments?.url); - const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - - try { - const res = await this.fetchWithPayment(url, { method }); - - if (!res.ok) { - if (res.status === 402) { - return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + if (toolName === 'paid_call') { + if (!this.fetchWithPayment || this.configuredNetworks.length === 0) { + return this.createErrorResponse( + 'ERR_MISSING_KEY', + 'No payment keys are configured. The server is running in read-only mode.' + ); + } + + const url = String(request.params.arguments?.url); + const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; + + try { + const res = await this.fetchWithPayment(url, { method }); + + if (!res.ok) { + if (res.status === 402) { + return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + } + return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + } + + const text = await res.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; } - return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + + return { + content: [ + { type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }, + ], + }; + } catch (e: any) { + return this.handlePaidCallError(e); + } + } + + if (toolName === 'lens_list_pairs') { + const limit = Math.min(Math.max(Number(request.params.arguments?.limit ?? 20), 1), 100); + const offset = Math.max(Number(request.params.arguments?.offset ?? 0), 0); + const network = (request.params.arguments?.network as NetworkName) ?? activeNetwork; + + try { + const allPairs = await getPairsData(network); + const total = allPairs.length; + const paginated = allPairs.slice(offset, offset + limit); + const hasMore = offset + limit < total; + const nextOffset = hasMore ? offset + limit : null; + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + total, + limit, + offset, + hasMore, + nextOffset, + pairs: paginated, + }, + null, + 2 + ), + }, + ], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message); + } + } + + if (toolName === 'lens_get_pools') { + const limit = Math.min(Math.max(Number(request.params.arguments?.limit ?? 20), 1), 100); + const offset = Math.max(Number(request.params.arguments?.offset ?? 0), 0); + const network = (request.params.arguments?.network as NetworkName) ?? activeNetwork; + + try { + const allPools = await getPoolsData(network); + const total = allPools.length; + const paginated = allPools.slice(offset, offset + limit); + const hasMore = offset + limit < total; + const nextOffset = hasMore ? offset + limit : null; + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + total, + limit, + offset, + hasMore, + nextOffset, + pools: paginated, + }, + null, + 2 + ), + }, + ], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message); } - - const text = await res.text(); - let parsed; + } + + if (toolName === 'lens_screen_pairs') { + const args = request.params.arguments ?? {}; try { - parsed = JSON.parse(text); - } catch { - parsed = text; + const screenerResult = await getScreenerData({ + limit: args.limit !== undefined ? Number(args.limit) : undefined, + cursor: args.cursor ? String(args.cursor) : undefined, + sortBy: args.sortBy ? String(args.sortBy) : undefined, + order: args.order ? String(args.order) : undefined, + volume: args.volume !== undefined ? Number(args.volume) : undefined, + liquidity: args.liquidity !== undefined ? Number(args.liquidity) : undefined, + change_24h_min: args.change_24h_min !== undefined ? Number(args.change_24h_min) : undefined, + change_24h_max: args.change_24h_max !== undefined ? Number(args.change_24h_max) : undefined, + price_min: args.price_min !== undefined ? Number(args.price_min) : undefined, + price_max: args.price_max !== undefined ? Number(args.price_max) : undefined, + network: (args.network as NetworkName) ?? activeNetwork, + }); + + return { + content: [{ type: 'text', text: JSON.stringify(screenerResult, null, 2) }], + }; + } catch (e: any) { + if (e instanceof ScreenerValidationError) { + return this.createErrorResponse('ERR_BAD_REQUEST', e.message); + } + return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message); + } + } + + throw new Error(`Unknown tool: ${toolName}`); + } finally { + this.inFlightCalls--; + } + }); + + this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ + { + uri: 'lens://pairs', + name: 'Watched Pairs', + description: 'Active watched asset pairs across configured Stellar networks', + mimeType: 'application/json', + }, + { + uri: 'lens://venues', + name: 'Trading Venues', + description: 'Supported execution venues on Stellar (SDEX, AMM, Soroswap, Aquarius)', + mimeType: 'application/json', + }, + { + uri: 'lens://openapi', + name: 'OpenAPI Specification', + description: 'Complete OpenAPI 3.0 specification for the Lens API', + mimeType: 'application/json', + }, + { + uri: 'lens://status', + name: 'Service Status', + description: 'Lens service status, health, and enabled networks', + mimeType: 'application/json', + }, + ], + })); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + + if (uri === 'lens://pairs') { + const pairsData = { + network: activeNetwork, + pairs: config.pairs, + }; + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(pairsData, null, 2), + _meta: { + version: '1.0.0', + lastModified: new Date().toISOString(), + }, + }, + ], + }; + } + + if (uri === 'lens://venues') { + const venuesData = { + venues: [ + { + id: 'sdex', + name: 'Stellar DEX', + type: 'orderbook', + description: 'Native Stellar decentralized exchange orderbook', + }, + { + id: 'amm', + name: 'Stellar AMM', + type: 'liquidity_pool', + description: 'Stellar protocol native liquidity pools', + }, + { + id: 'soroswap', + name: 'Soroswap', + type: 'soroban_amm', + description: 'Soroban smart contract AMM protocol', + }, + { + id: 'aquarius', + name: 'Aquarius', + type: 'liquidity_pool', + description: 'Aquarius AMM liquidity pools', + }, + ], + }; + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(venuesData, null, 2), + _meta: { + version: '1.0.0', + }, + }, + ], + }; + } + + if (uri === 'lens://openapi') { + const candidates = [ + this.openapiPath, + resolve(process.cwd(), 'openapi.json'), + resolve(__dirname, '../../openapi.json'), + resolve(__dirname, '../../../openapi.json'), + ].filter(Boolean) as string[]; + + let openapiContent = '{}'; + let lastModified = new Date().toISOString(); + + for (const candidate of candidates) { + if (existsSync(candidate)) { + try { + openapiContent = readFileSync(candidate, 'utf8'); + const stat = statSync(candidate); + lastModified = stat.mtime.toISOString(); + break; + } catch { + // try next candidate + } } - - return { - content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], - }; - } catch (e: any) { - return this.handlePaidCallError(e); } + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: openapiContent, + _meta: { + version: '0.1.0', + lastModified, + }, + }, + ], + }; + } + + if (uri === 'lens://status') { + const statusData = { + status: 'ok', + service: 'lens', + version: '0.1.0', + activeNetwork, + timestamp: new Date().toISOString(), + }; + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(statusData, null, 2), + _meta: { + version: '0.1.0', + lastModified: statusData.timestamp, + }, + }, + ], + }; } - throw new Error(`Unknown tool: ${request.params.name}`); + throw new Error(`Resource not found: ${uri}`); }); } private handlePaidCallError(e: any) { - const msg = String(e.message || e).toLowerCase(); - - if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { - return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + const msg = String(e?.message || e); + const lower = msg.toLowerCase(); + + // Check for missing key / network scheme errors + if ( + lower.includes('no key') || + lower.includes('missing key') || + lower.includes('no matching payment scheme') || + lower.includes('scheme not found') || + lower.includes('not configured for network') || + lower.includes('unsupported network') + ) { + const netMatch = msg.match(/stellar:(testnet|pubnet)/i); + const network = netMatch ? netMatch[0] : undefined; + return this.createErrorResponse( + 'ERR_MISSING_KEY', + network + ? `Missing secret key for network: ${network}` + : `Missing secret key for required payment network: ${msg}` + ); + } + + if (lower.includes('signatureexpirationledger') || lower.includes('signature expired')) { + return this.createErrorResponse( + 'ERR_EXPIRED_AUTHORISATION', + 'The transaction signature expired before it could be submitted.' + ); } - - if (msg.includes('trustline') || msg.includes('op_no_trust')) { + + if (lower.includes('trustline') || lower.includes('op_no_trust')) { return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); } - - if (msg.includes('insufficient') || msg.includes('underfunded')) { + + if (lower.includes('insufficient') || lower.includes('underfunded')) { return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); } - if (msg.includes('fetch') || msg.includes('network')) { + if (lower.includes('fetch') || lower.includes('network')) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); } - - return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + + return this.createErrorResponse( + 'ERR_INTERNAL_FAILURE', + e.message || 'An unknown error occurred during the paid call.' + ); } - private createErrorResponse(code: McpErrorCode, reason: string) { + private createErrorResponse(code: McpErrorCode, reason: string, extra?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, reason, + ...extra, }; return { content: [{ type: 'text', text: JSON.stringify(payload) }], diff --git a/src/metrics.ts b/src/metrics.ts index 39a3a505..e9c6a070 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -59,6 +59,14 @@ export const last_trade_timestamp = new Gauge({ registers: [register] }) +export const mcp_rate_limit_rejections_total = new Counter({ + name: 'mcp_rate_limit_rejections_total', + help: 'Total number of MCP tool calls rejected due to rate limits or concurrency limits', + labelNames: ['tool', 'reason'], + registers: [register] +}) + + export const http_requests_total = new Counter({ name: 'http_requests_total', help: 'Total number of HTTP requests served, by method, route template and status class', diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index f2208e04..a85c6680 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -1,9 +1,47 @@ import type { FastifyInstance } from 'fastify' import { getActivePairs, parseAssetStr, makePairKey, registerPair, persistPair, hasPair } from '../pairsRegistry' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type +export async function getPairsData(network: NetworkName = activeNetwork) { + const activePairs = getActivePairs() + if (activePairs.length === 0) return [] + + const result = await pgPool.query( + `SELECT p.pair_key, l.price, l.timestamp + FROM unnest($2::text[]) AS p(pair_key) + CROSS JOIN LATERAL ( + SELECT price, timestamp + FROM price_points + WHERE network = $1 + AND pair_key = p.pair_key + ORDER BY timestamp DESC + LIMIT 1 + ) l`, + [network, activePairs.map(p => p.pairKey)] + ) + + const latestPrices = new Map() + result.rows.forEach(row => { + latestPrices.set(row.pair_key, { + price: parseFloat(row.price), + timestamp: row.timestamp, + }) + }) + + return activePairs.map(p => { + const latest = latestPrices.get(p.pairKey) + return { + pairKey: p.pairKey, + assetA: p.assetA, + assetB: p.assetB, + latestPrice: latest?.price ?? null, + lastUpdated: latest?.timestamp ?? null, + } + }) +} + export async function registerPairsRoutes(app: FastifyInstance) { // GET /pairs — list all active pairs with latest price metadata // @@ -17,45 +55,12 @@ export async function registerPairsRoutes(app: FastifyInstance) { // real `lastUpdated`, so a caller can see it is old. A pair with no rows on // this network at all comes back with null price and timestamp. app.get('/pairs', async (req) => { - const activePairs = getActivePairs() const network = req.network ?? activeNetwork - - const result = await pgPool.query( - `SELECT p.pair_key, l.price, l.timestamp - FROM unnest($2::text[]) AS p(pair_key) - CROSS JOIN LATERAL ( - SELECT price, timestamp - FROM price_points - WHERE network = $1 - AND pair_key = p.pair_key - ORDER BY timestamp DESC - LIMIT 1 - ) l`, - [network, activePairs.map(p => p.pairKey)] - ) - - const latestPrices = new Map() - result.rows.forEach(row => { - latestPrices.set(row.pair_key, { - price: parseFloat(row.price), - timestamp: row.timestamp, - }) - }) - - return { - pairs: activePairs.map(p => { - const latest = latestPrices.get(p.pairKey) - return { - pairKey: p.pairKey, - assetA: p.assetA, - assetB: p.assetB, - latestPrice: latest?.price ?? null, - lastUpdated: latest?.timestamp ?? null, - } - }) - } + const pairs = await getPairsData(network) + return { pairs } }) + // POST /pairs — add a new trading pair at runtime app.post<{ Body: { assetA?: string; assetB?: string } diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 783059d4..90e0f69e 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -1,14 +1,189 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type // Lens has no circulating-supply data, so a market cap cannot be computed. It // used to be reported as a copy of `liquidity`; a caller who sorts or filters on // it must be told it is gone rather than have the parameter silently ignored. -const MARKET_CAP_UNSUPPORTED = +export const MARKET_CAP_UNSUPPORTED = 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' +export class ScreenerValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ScreenerValidationError' + } +} + +export interface ScreenerQueryOptions { + volume?: number + change_24h_min?: number + change_24h_max?: number + market_cap?: unknown + price_min?: number + price_max?: number + liquidity?: number + sortBy?: string + order?: string + cursor?: string + limit?: number + network?: NetworkName +} + +export async function getScreenerData(options: ScreenerQueryOptions = {}) { + const limit = Math.min(Math.max(options.limit ?? 20, 1), 100) + if (isNaN(limit)) { + throw new ScreenerValidationError('limit must be a valid integer') + } + + if (options.market_cap !== undefined || options.sortBy === 'market_cap') { + throw new ScreenerValidationError(MARKET_CAP_UNSUPPORTED) + } + + const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] + const sortBy = options.sortBy ?? 'volume' + if (!allowedSortFields.includes(sortBy)) { + throw new ScreenerValidationError(`sortBy must be one of: ${allowedSortFields.join(', ')}`) + } + + const order = (options.order ?? 'desc').toLowerCase() + if (order !== 'asc' && order !== 'desc') { + throw new ScreenerValidationError('order must be asc or desc') + } + + const filters = { + volume: options.volume, + change_24h_min: options.change_24h_min, + change_24h_max: options.change_24h_max, + price_min: options.price_min, + price_max: options.price_max, + liquidity: options.liquidity, + } + + for (const [key, val] of Object.entries(filters)) { + if (val !== undefined && isNaN(val)) { + throw new ScreenerValidationError(`Filter ${key} must be a valid number`) + } + } + + const conditions: string[] = [] + const params: any[] = [options.network ?? activeNetwork] + let paramIdx = 2 + + if (filters.volume !== undefined) { + conditions.push(`volume >= $${paramIdx++}`) + params.push(filters.volume) + } + if (filters.change_24h_min !== undefined) { + conditions.push(`change_24h >= $${paramIdx++}`) + params.push(filters.change_24h_min) + } + if (filters.change_24h_max !== undefined) { + conditions.push(`change_24h <= $${paramIdx++}`) + params.push(filters.change_24h_max) + } + if (filters.price_min !== undefined) { + conditions.push(`price >= $${paramIdx++}`) + params.push(filters.price_min) + } + if (filters.price_max !== undefined) { + conditions.push(`price <= $${paramIdx++}`) + params.push(filters.price_max) + } + if (filters.liquidity !== undefined) { + conditions.push(`liquidity >= $${paramIdx++}`) + params.push(filters.liquidity) + } + + if (options.cursor) { + try { + const decoded = Buffer.from(options.cursor, 'base64').toString('utf-8') + const [lastVal, lastId] = JSON.parse(decoded) + + if (lastVal !== null && lastVal !== undefined && lastId) { + const op = order === 'desc' ? '<' : '>' + conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) + params.push(lastVal, lastId) + } + } catch { + throw new ScreenerValidationError('Invalid cursor format') + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const sql = ` + WITH pair_pools AS ( + SELECT DISTINCT pair_key, pool_id + FROM price_points + WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL + ), + latest_snapshots AS ( + SELECT DISTINCT ON (ps.pool_id) + ps.pool_id, + (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq + FROM pool_snapshots ps + WHERE ps.network = $1 + ORDER BY ps.pool_id, ps.timestamp DESC + ), + pair_liquidity AS ( + SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity + FROM pair_pools pp + JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id + GROUP BY pp.pair_key + ), + latest_aggregates AS ( + SELECT DISTINCT ON (pair_key) + pair_key, + COALESCE(volume::float, 0) AS volume, + COALESCE(close_price::float, 0) AS price, + CASE + WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 + ELSE 0 + END AS change_24h + FROM price_aggregates + WHERE network = $1 AND window = '24h' + ORDER BY pair_key, bucket DESC + ), + screener_data AS ( + SELECT + la.pair_key, + la.volume, + la.price, + la.change_24h, + COALESCE(pl.liquidity::float, 0) AS liquidity + FROM latest_aggregates la + LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key + ) + SELECT * FROM screener_data + ${whereClause} + ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} + LIMIT $${paramIdx} + ` + + params.push(limit + 1) + const result = await pgPool.query(sql, params) + const rows = result.rows + + const hasMore = rows.length > limit + const data = hasMore ? rows.slice(0, limit) : rows + + let nextCursor = null + if (hasMore && data.length > 0) { + const lastRecord = data[data.length - 1] + const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) + nextCursor = Buffer.from(cursorPayload).toString('base64') + } + + return { + data, + nextCursor, + hasMore, + total: null, + } +} + export async function registerScreenerRoutes(app: FastifyInstance) { app.get<{ Querystring: { @@ -27,168 +202,29 @@ export async function registerScreenerRoutes(app: FastifyInstance) { }>('/screener', async (req, reply) => { try { const q = req.query + const parseNum = (val?: string) => (val ? parseFloat(val) : undefined) + const parseIntNum = (val?: string) => (val ? parseInt(val, 10) : undefined) - // 1. Parse and validate pagination & sorting - const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10), 1), 100) - if (isNaN(limit)) { - return reply.status(400).send({ error: 'limit must be a valid integer' }) - } - - if (q.market_cap !== undefined || q.sortBy === 'market_cap') { - return reply.status(400).send({ error: MARKET_CAP_UNSUPPORTED }) - } - - const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] - const sortBy = q.sortBy ?? 'volume' - if (!allowedSortFields.includes(sortBy)) { - return reply.status(400).send({ error: `sortBy must be one of: ${allowedSortFields.join(', ')}` }) - } - - const order = (q.order ?? 'desc').toLowerCase() - if (order !== 'asc' && order !== 'desc') { - return reply.status(400).send({ error: 'order must be asc or desc' }) - } - - // 2. Parse filters - const parseNum = (val?: string) => val ? parseFloat(val) : undefined - const filters = { + const result = await getScreenerData({ volume: parseNum(q.volume), change_24h_min: parseNum(q.change_24h_min), change_24h_max: parseNum(q.change_24h_max), + market_cap: q.market_cap, price_min: parseNum(q.price_min), price_max: parseNum(q.price_max), liquidity: parseNum(q.liquidity), + sortBy: q.sortBy, + order: q.order, + cursor: q.cursor, + limit: parseIntNum(q.limit), + network: req.network ?? activeNetwork, + }) + + return result + } catch (error: any) { + if (error instanceof ScreenerValidationError) { + return reply.status(400).send({ error: error.message }) } - - for (const [key, val] of Object.entries(filters)) { - if (val !== undefined && isNaN(val)) { - return reply.status(400).send({ error: `Filter ${key} must be a valid number` }) - } - } - - // 3. Build query parameters and WHERE conditions - const conditions: string[] = [] - // Every CTE below is scoped to one network: testnet and mainnet rows share - // these tables, and pooling them would rank a pair on the other chain's data. - const params: any[] = [req.network ?? activeNetwork] - let paramIdx = 2 - - if (filters.volume !== undefined) { - conditions.push(`volume >= $${paramIdx++}`) - params.push(filters.volume) - } - if (filters.change_24h_min !== undefined) { - conditions.push(`change_24h >= $${paramIdx++}`) - params.push(filters.change_24h_min) - } - if (filters.change_24h_max !== undefined) { - conditions.push(`change_24h <= $${paramIdx++}`) - params.push(filters.change_24h_max) - } - if (filters.price_min !== undefined) { - conditions.push(`price >= $${paramIdx++}`) - params.push(filters.price_min) - } - if (filters.price_max !== undefined) { - conditions.push(`price <= $${paramIdx++}`) - params.push(filters.price_max) - } - if (filters.liquidity !== undefined) { - conditions.push(`liquidity >= $${paramIdx++}`) - params.push(filters.liquidity) - } - - // 4. Cursor decoding and pagination condition - if (q.cursor) { - try { - const decoded = Buffer.from(q.cursor, 'base64').toString('utf-8') - const [lastVal, lastId] = JSON.parse(decoded) - - if (lastVal !== null && lastVal !== undefined && lastId) { - const op = order === 'desc' ? '<' : '>' - conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) - params.push(lastVal, lastId) - } - } catch (e) { - return reply.status(400).send({ error: 'Invalid cursor format' }) - } - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - - // 5. Construct the full SQL query using CTEs - const sql = ` - WITH pair_pools AS ( - SELECT DISTINCT pair_key, pool_id - FROM price_points - WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL - ), - latest_snapshots AS ( - SELECT DISTINCT ON (ps.pool_id) - ps.pool_id, - (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq - FROM pool_snapshots ps - WHERE ps.network = $1 - ORDER BY ps.pool_id, ps.timestamp DESC - ), - pair_liquidity AS ( - SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity - FROM pair_pools pp - JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id - GROUP BY pp.pair_key - ), - latest_aggregates AS ( - SELECT DISTINCT ON (pair_key) - pair_key, - COALESCE(volume::float, 0) AS volume, - COALESCE(close_price::float, 0) AS price, - CASE - WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 - ELSE 0 - END AS change_24h - FROM price_aggregates - WHERE network = $1 AND window = '24h' - ORDER BY pair_key, bucket DESC - ), - screener_data AS ( - SELECT - la.pair_key, - la.volume, - la.price, - la.change_24h, - COALESCE(pl.liquidity::float, 0) AS liquidity - FROM latest_aggregates la - LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key - ) - SELECT * FROM screener_data - ${whereClause} - ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} - LIMIT $${paramIdx} - ` - - // 6. Execute query - params.push(limit + 1) // Fetch one extra to determine hasMore - const result = await pgPool.query(sql, params) - const rows = result.rows - - const hasMore = rows.length > limit - const data = hasMore ? rows.slice(0, limit) : rows - - // 7. Generate nextCursor - let nextCursor = null - if (hasMore && data.length > 0) { - const lastRecord = data[data.length - 1] - const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) - nextCursor = Buffer.from(cursorPayload).toString('base64') - } - - return { - data, - nextCursor, - hasMore, - total: null, - } - } catch (error) { console.error('[screener]', error) return reply.status(500).send({ error: 'Internal server error' }) } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..748998a4 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,123 +1,441 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BazaarMcpServer } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import * as fetchModule from '../src/fetch'; +import * as pairsModule from '../src/routes/pairs'; +import * as restModule from '../src/api/rest'; +import * as screenerModule from '../src/routes/screener'; 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(); + }); + + describe('Tool Listing (#409)', () => { + it('advertises all 5 tools with descriptive metadata', async () => { + const server = new BazaarMcpServer(); + let listHandler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListToolsRequestSchema) { + listHandler = fn; } - } - }); + }; + (server as any).setupHandlers(); + + const result = await listHandler(); + const names = result.tools.map((t: any) => t.name); + expect(names).toContain('bazaar_search'); + expect(names).toContain('paid_call'); + expect(names).toContain('lens_list_pairs'); + expect(names).toContain('lens_get_pools'); + expect(names).toContain('lens_screen_pairs'); + + // Verify descriptions distinguish the tools + const listPairsDesc = result.tools.find((t: any) => t.name === 'lens_list_pairs').description; + const getPoolsDesc = result.tools.find((t: any) => t.name === 'lens_get_pools').description; + const screenPairsDesc = result.tools.find((t: any) => t.name === 'lens_screen_pairs').description; - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + expect(listPairsDesc).toContain('watched by Lens'); + expect(getPoolsDesc).toContain('liquidity pool reserves'); + expect(screenPairsDesc).toContain('Screen and rank'); + }); }); - 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' + describe('New Market Discovery Tools (#409)', () => { + it('lens_list_pairs returns bounded watched pairs with full issuers', async () => { + const mockPairs = [ + { + pairKey: 'XLM/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + latestPrice: 0.15, + lastUpdated: new Date('2026-10-17T12:00:00Z'), + }, + ]; + vi.spyOn(pairsModule, 'getPairsData').mockResolvedValue(mockPairs as any); + + const server = new BazaarMcpServer(); + 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: 'lens_list_pairs', + arguments: { limit: 10, offset: 0, network: 'testnet' }, + }, + }); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.total).toBe(1); + expect(content.pairs).toHaveLength(1); + expect(content.pairs[0].assetB.issuer).toBe('GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + }); + + it('lens_get_pools returns bounded liquidity pools sharing rest route implementation', async () => { + const mockPools = [ + { + pool_id: 'pool_1234567890abcdef', + asset_a: 'XLM:native', + asset_b: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + reserve_a: 100000, + reserve_b: 15000, + spot_price: 0.15, + fee_bp: 30, + timestamp: new Date('2026-10-17T12:00:00Z'), + }, + ]; + vi.spyOn(restModule, 'getPoolsData').mockResolvedValue(mockPools as any); + + const server = new BazaarMcpServer(); + 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: 'lens_get_pools', + arguments: { limit: 10, offset: 0, network: 'testnet' }, + }, + }); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.total).toBe(1); + expect(content.pools[0].pool_id).toBe('pool_1234567890abcdef'); + expect(content.pools[0].fee_bp).toBe(30); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + it('lens_screen_pairs screens pairs and enforces parameter validation parity with REST', async () => { + const mockScreenerResult = { + data: [ + { + pair_key: 'XLM/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + volume: 500000, + price: 0.15, + change_24h: 3.5, + liquidity: 1200000, + }, + ], + nextCursor: null, + hasMore: false, + total: null, + }; + vi.spyOn(screenerModule, 'getScreenerData').mockResolvedValue(mockScreenerResult as any); + + const server = new BazaarMcpServer(); + 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: 'lens_screen_pairs', + arguments: { sortBy: 'volume', order: 'desc', limit: 10 }, + }, + }); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.data).toHaveLength(1); + expect(content.data[0].volume).toBe(500000); + + // Verify market_cap rejection parity with REST route + vi.spyOn(screenerModule, 'getScreenerData').mockRejectedValue( + new screenerModule.ScreenerValidationError(screenerModule.MARKET_CAP_UNSUPPORTED) + ); + + const invalidResponse = await handler({ + params: { + name: 'lens_screen_pairs', + arguments: { sortBy: 'market_cap' }, + }, + }); + + expect(invalidResponse.isError).toBe(true); + const errContent = JSON.parse(invalidResponse.content[0].text); + expect(errContent.code).toBe('ERR_BAD_REQUEST'); + expect(errContent.reason).toContain('market_cap is not supported'); + }); }); - 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('Upstream Robustness & Error Handling (#349)', () => { + it('rejects upstream request that exceeds deadline with structured error', async () => { + const timeoutErr = new Error('The operation was aborted due to timeout'); + timeoutErr.name = 'TimeoutError'; + vi.spyOn(fetchModule, 'fetchWithTimeout').mockRejectedValue(timeoutErr); + + const server = new BazaarMcpServer(); + 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: 'bazaar_search', + arguments: { url: 'http://slow-service.example.com', query: 'XLM' }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(content.reason).toContain('slow-service.example.com'); + expect(content.reason).toContain('timed out'); }); - - 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('rejects upstream non-2xx responses naming the upstream and status code', async () => { + vi.spyOn(fetchModule, 'fetchWithTimeout').mockResolvedValue({ + ok: false, + status: 502, + headers: new Headers({ 'content-type': 'text/html' }), + text: async () => 'Bad Gateway', + } as any); + + const server = new BazaarMcpServer(); + 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: 'bazaar_search', + arguments: { url: 'http://broken-service.example.com' }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(content.reason).toContain('broken-service.example.com'); + expect(content.reason).toContain('502'); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + it('rejects non-JSON response from upstream before parsing', async () => { + vi.spyOn(fetchModule, 'fetchWithTimeout').mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), + text: async () => 'Welcome', + } as any); + + const server = new BazaarMcpServer(); + 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: 'bazaar_search', + arguments: { url: 'http://html-service.example.com' }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(content.reason).toContain('non-JSON content-type'); + expect(content.reason).toContain('html-service.example.com'); + }); }); - - 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('Paid Call Error Mapping', () => { + 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 }); + + 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', + }, + }, + }); + + 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 }); + + 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', + }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); }); - - 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 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 }); + + 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', + }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + 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 }); + + 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', + }, + }, + }); + + expect(response.isError).toBeFalsy(); + const parsedText = JSON.parse(response.content[0].text); + expect(parsedText.success).toBe(true); + }); + }); + + describe('Resources Listing & Reading', () => { + it('lists stable resources including pairs, venues, openapi, and status', async () => { + const server = new BazaarMcpServer(); + + let listHandler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListResourcesRequestSchema) { + listHandler = fn; + } + }; + (server as any).setupHandlers(); + + const result = await listHandler(); + expect(result.resources).toBeDefined(); + expect(result.resources.length).toBeGreaterThanOrEqual(4); + + const uris = result.resources.map((r: any) => r.uri); + expect(uris).toContain('lens://pairs'); + expect(uris).toContain('lens://venues'); + expect(uris).toContain('lens://openapi'); + expect(uris).toContain('lens://status'); + }); + + it('reads lens://pairs resource with valid JSON and freshness meta', async () => { + const server = new BazaarMcpServer(); + + let readHandler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ReadResourceRequestSchema) { + readHandler = fn; + } + }; + (server as any).setupHandlers(); + + const result = await readHandler({ params: { uri: 'lens://pairs' } }); + expect(result.contents).toHaveLength(1); + const item = result.contents[0]; + expect(item.uri).toBe('lens://pairs'); + expect(item.mimeType).toBe('application/json'); + expect(item._meta.version).toBeDefined(); + expect(item._meta.lastModified).toBeDefined(); + + const parsed = JSON.parse(item.text); + expect(parsed.pairs).toBeDefined(); + expect(parsed.network).toBeDefined(); + }); }); });