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 3937aedc..e8909829 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,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. @@ -225,3 +230,30 @@ MCP_PAID_CALL_ALLOWED_HOSTS= # 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 13334c3b..04b8dc14 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 index bada952f..0a357068 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -1,192 +1,136 @@ -# Bazaar MCP Server Example +# Lens Model Context Protocol (MCP) Server & Agent Example -This directory contains two runnable examples that expose the Lens Bazaar -catalog as a [Model Context Protocol](https://modelcontextprotocol.io) -(MCP) server: +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/). | Command | File | What it does | |---|---|---| | `npm run mcp:server` | `run.ts` | Starts the Bazaar MCP server on stdio. It signs x402 payment challenges with two Stellar secret keys — one for testnet, one for mainnet — and forwards paid API calls to Bazaar resources, handling the 402 → sign → 200 loop automatically. | | `npm run mcp:agent` | `agent.ts` | Starts a mock Bazaar API on `http://localhost:3000`, connects an MCP client to the server, lists the available tools, calls `bazaar_search`, then makes a `paid_call` to the discovered resource. End-to-end demonstration of an agent discovering and paying for a resource. | -## ⚠️ Warning: this signs with a real mainnet key - -`run.ts` requires `MCP_AGENT_SECRET_KEY_MAINNET` and builds a signer for -`stellar:pubnet` (mainnet). Any payment the server signs spends **real XLM** -from the account that key controls — there is no sandbox, no dry-run, and no -confirmation prompt. +--- -**Do not run these commands with a funded mainnet key until you have read the -"Safe way to try it first" section below.** +## ⚠️ CRITICAL SPENDING WARNING & MAINNET SAFEGUARDS -The MCP server can spend up to `MCP_MAX_PAYMENT_PRICE` (default `$1.00`) per -paid call from the mainnet account. +> **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.** -## Configuration +--- -| Variable | Required | Description | -|---|---|---| -| `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` | -| `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** | -| `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | +## Configuration (`.env`) -Both keys are required: `run.ts` exits with an error if either is unset. +Configure the following environment variables in your `.env` file: -The server talks to two Stellar networks: -- `stellar:testnet` — safe, friendbot-funded play money -- `stellar:pubnet` — mainnet, real XLM +| 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_PAID_CALL_ALLOWED_HOSTS` | Optional | `""` | Comma-separated allowlist of origins/hosts that `paid_call` may pay for. If empty, all paid calls fail closed. | +| `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. | -## Safe way to try it first +--- -Never test with a funded mainnet key. Instead: +## Available MCP Tools -1. Create a fresh testnet account and fund it via [friendbot](https://docs.stellar.org/create-account) so it holds testnet XLM. Set `MCP_AGENT_SECRET_KEY_TESTNET` to that testnet secret. -2. For `MCP_AGENT_SECRET_KEY_MAINNET`, use a brand-new mainnet account with **zero XLM** (or any account you can afford to lose). A payment attempt against an unfunded account fails deterministically with `ERR_INSUFFICIENT_BALANCE` instead of spending money. -3. Leave `MCP_MAX_PAYMENT_PRICE` at its default `$1.00`, or lower it. +The server advertises the following tools to connected agents: -Only once you have confirmed the flow behaves as expected on testnet should you consider pointing `MCP_AGENT_SECRET_KEY_MAINNET` at a funded mainnet account — and only with an amount you are prepared to spend. +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. -Never log, echo, print, or commit a real secret key. This README shows env var names only; it never shows a key value. +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. -## Worked example: connect a client and call each tool +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`). -Prerequisites: +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. -- Node.js installed -- Dependencies installed at the repo root (`npm install`) -- The two secret-key env vars set (see "Safe way to try it first" above) +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. -Run the server in one terminal: - -```bash -export MCP_AGENT_SECRET_KEY_TESTNET= -export MCP_AGENT_SECRET_KEY_MAINNET= -npm run mcp:server -``` +--- -The server starts on stdio and logs: +## Available MCP Resources -``` -[mcp-server] Starting Bazaar MCP server on stdio... -[mcp-server] Agent configured for stellar:testnet and stellar:pubnet -``` +In addition to tools, the server advertises read-only resources: -In another terminal, run the agent example, which connects an MCP client, -lists the tools, and calls both of them: +- **`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. -```bash -npm run mcp:agent -``` +--- -Expected output (shortened for readability here; the actual run prints full -JSON): +## Error Codes -``` -[mock-server] Started mock Bazaar API on http://localhost:3000 -[agent] Starting MCP server child process... -[agent] Connected to Bazaar MCP server. -[agent] Available tools: bazaar_search, paid_call - -[agent] 1. Discovering resources at http://localhost:3000... -[agent] Executing tool: bazaar_search -[agent] Search result: {"resources":[{"name":"Mock Price Feed",...}]} - -[agent] 2. Making a paid call to http://localhost:3000/api/v1/price... -[agent] Executing tool: paid_call -[agent] The MCP server will handle the 402 loop automatically... -[agent] Paid call succeeded! Retrieved data: -{"asset":"XLM","price":0.15,"timestamp":"..."} - -[agent] End to end demonstration complete. -``` +Paid-call and tool failures return deterministic, machine-readable codes (see `src/mcp/codes.ts`): -### What each tool does - -- `bazaar_search` — searches a Bazaar node's discovery endpoint. Takes `url` - (base URL of the Bazaar API) and an optional `query`. Returns the catalog of - discoverable resources, or a structured error such as `ERR_NO_RESULTS` or - `ERR_SERVICE_UNREACHABLE`. -- `paid_call` — makes a paid API call to a Bazaar resource, handling the x402 - 402 payment loop automatically. Takes `url` (full URL of the resource) and an - optional `method` (default `GET`). Returns the resource body, or a structured - error such as `ERR_PAYMENT_REJECTED`, `ERR_INSUFFICIENT_BALANCE`, - `ERR_MISSING_TRUSTLINE`, `ERR_EXPIRED_AUTHORISATION`, or - `ERR_SERVICE_UNREACHABLE`. +| Code | Meaning | +|---|---| +| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed | +| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay | +| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset | +| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission | +| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached | +| `ERR_NO_RESULTS` | The search returned no resources | +| `ERR_BAD_REQUEST` | The request failed with a non-402 error status or invalid arguments | +| `ERR_MISSING_KEY` | Server has no payment keys configured for the requested network | +| `ERR_RATE_LIMITED` | Per-tool rate limit exceeded | +| `ERR_CONCURRENCY_LIMIT_EXCEEDED` | Maximum in-flight concurrent requests exceeded | +| `ERR_URL_BLOCKED` | Destination URL blocked by SSRF defense | +| `ERR_HOST_NOT_ALLOWED` | Target host not on the operator's paid_call allowlist | +| `ERR_INTERNAL_FAILURE` | An unknown internal error occurred | -### Standalone client example +--- -You can also connect your own MCP client to the server. This TypeScript -example connects via stdio, lists tools, and calls both tools: +## Client Example ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'node:path'; -async function main() { +async function runClient() { const transport = new StdioClientTransport({ command: process.platform === 'win32' ? 'npx.cmd' : 'npx', - args: ['tsx', 'examples/mcp-server/run.ts'], + args: ['tsx', path.resolve(__dirname, 'run.ts')], }); const client = new Client( - { name: 'bazaar-client-example', version: '1.0.0' }, - { capabilities: {} }, + { name: 'lens-example-client', version: '1.0.0' }, + { capabilities: {} } ); await client.connect(transport); + console.log('Connected to Lens MCP server.'); const tools = await client.listTools(); - console.log('Available tools:', tools.tools.map((t) => t.name).join(', ')); + console.log('Available tools:', tools.tools.map((t) => t.name)); - // 1. Search for resources - const search = await client.callTool({ - name: 'bazaar_search', - arguments: { url: 'http://localhost:3000', query: 'XLM price' }, - }); - console.log('Search:', search.content[0].text); - - // 2. Make a paid call to the discovered resource - const paid = await client.callTool({ - name: 'paid_call', - arguments: { url: 'http://localhost:3000/api/v1/price' }, - }); - console.log('Paid call:', paid.content[0].text); + const resources = await client.listResources(); + console.log('Available resources:', resources.resources.map((r) => r.uri)); await client.close(); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +runClient().catch(console.error); ``` - -Run it with `npx tsx ` from the repo root, with the secret-key env vars -set. The server process is spawned automatically by the transport. - -## Error codes - -Paid-call failures return deterministic, machine-readable codes (see -`src/mcp/codes.ts`): - -| Code | Meaning | -|---|---| -| `ERR_PAYMENT_REJECTED` | Payment was required but rejected or failed | -| `ERR_INSUFFICIENT_BALANCE` | The signing account lacks the balance to pay | -| `ERR_MISSING_TRUSTLINE` | Missing trustline for the required asset | -| `ERR_EXPIRED_AUTHORISATION` | The transaction signature expired before submission | -| `ERR_SERVICE_UNREACHABLE` | The Bazaar service could not be reached | -| `ERR_NO_RESULTS` | The search returned no resources | -| `ERR_BAD_REQUEST` | The request failed with a non-402 error status | -| `ERR_INTERNAL_FAILURE` | An unknown error occurred during the paid call | - -An agent is the least able caller to notice a wrong answer: the server -prefers returning a structured error over guessing a best-effort result, so -always check `isError` before acting on a tool response. - -## Related - -- MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts) -- Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts) -- Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts) -- Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol) diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ef9da239..a09da903 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -7,31 +7,49 @@ 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; // paid_call spends from the keys above, so the hosts it may pay for are the // operator's decision. Unset means no host is payable at all (#404). @@ -42,11 +60,29 @@ async function main() { const server = new BazaarMcpServer({ fetchWithPayment, - paidCallAllowlist + paidCallAllowlist, + 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)' + }` + ); console.error( paidCallAllowlist.length > 0 ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` @@ -56,7 +92,7 @@ async function main() { 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 c9a76967..0bc19e72 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,14 +6,17 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', - // Refused before any request was made, for two different reasons: + 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', + // Refused before any request was made: // ERR_URL_BLOCKED the target resolves somewhere we will never fetch // (private, loopback, link-local, metadata) — #488 // ERR_HOST_NOT_ALLOWED the host is reachable but is not on the operator's // paid_call allow-list — #487 ERR_URL_BLOCKED: 'ERR_URL_BLOCKED', ERR_HOST_NOT_ALLOWED: 'ERR_HOST_NOT_ALLOWED', - ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' } as const; export type McpErrorCode = keyof typeof MCP_ERROR_CODES; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..0d41b283 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,14 +1,29 @@ 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 { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; import { fetchWithTimeout } from '../fetch.js'; -import { config } from '../config.js'; +import { mcp_rate_limit_rejections_total } from '../metrics.js'; +import { config, activeNetwork, NetworkName } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.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; /** * Origins (or URL prefixes) `paid_call` may pay for. Operator configuration, * never a tool argument: an empty or absent list — the default — means @@ -21,31 +36,49 @@ export interface McpServerConfig { * tests can drive DNS resolution and the transport without real sockets. */ bazaarFetch?: Partial; + 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; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; - constructor(config_: McpServerConfig) { - this.fetchWithPayment = config_.fetchWithPayment; + 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; - // Two independent gates, deliberately separate: this one decides which - // hosts may be PAID (#404), the one below decides which addresses may be - // REACHED at all (#407). Neither can re-open what the other refuses. - const allowlist = parsePaidCallAllowlist(config_.paidCallAllowlist ?? []); + const allowlist = parsePaidCallAllowlist(configObj.paidCallAllowlist ?? []); this.paidCallRules = allowlist.rules; if (allowlist.rejected.length > 0) { - // Unparseable entries match nothing rather than matching everything. console.error(`[mcp-server] Ignoring unreadable paid_call allow-list entries: ${allowlist.rejected.join(', ')}`); } this.bazaarFetchOptions = { allowedHosts: config.outbound.allowedHosts, fetchImpl: (input, init) => fetchWithTimeout(input, init), - ...config_.bazaarFetch, + ...configObj.bazaarFetch, }; this.server = new Server( { @@ -55,6 +88,7 @@ export class BazaarMcpServer { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -62,6 +96,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: [ @@ -107,114 +184,546 @@ export class BazaarMcpServer { additionalProperties: false, }, }, + { + 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 ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); - - if (!res.ok) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); + try { + const res = await ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); + + 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('text/html')) { + return this.createErrorResponse( + 'ERR_SERVICE_UNREACHABLE', + `Upstream service at ${endpoint} returned non-JSON content-type: ${contentType}` + ); + } + + 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) { + if (e instanceof SsrfError) { + return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); + } + 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); } - - 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'); + } + + 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.' + ); } - - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; - } catch (e: any) { - if (e instanceof SsrfError) { - return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); + + const url = String(request.params.arguments?.url); + const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; + + // Gate before the fetch: check allowlist + const target = checkPaidCallTarget(url, this.paidCallRules); + if (!target.allowed) { + return this.createErrorResponse(target.code, target.reason); + } + + 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 { + content: [ + { type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }, + ], + }; + } catch (e: any) { + return this.handlePaidCallError(e); } - 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'; + 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; - // Gate before the fetch: fetchWithPayment answers a 402 by paying, so - // by the time a request leaves this process the money is already spent. - const target = checkPaidCallTarget(url, this.paidCallRules); - if (!target.allowed) { - return this.createErrorResponse(target.code, target.reason); + 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); + } } - 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}`); + 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 199ada5a..8c53eada 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 be7e5aff..845d2ab1 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -1,16 +1,23 @@ import type { FastifyInstance } from 'fastify' import { z } from 'zod' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' import { formatValidationError, VALIDATION_ERROR_CODE } from '../validation' 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' + } +} + /** Machine-readable code for the market_cap rejection (not a validation error). */ const MARKET_CAP_CODE = 'UNSUPPORTED_PARAMETER' @@ -69,6 +76,173 @@ const ScreenerQuerySchema = z } }) +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 sortBy = options.sortBy ?? 'volume' + if (!(ALLOWED_SORT_FIELDS as readonly string[]).includes(sortBy)) { + throw new ScreenerValidationError(`sortBy must be one of: ${ALLOWED_SORT_FIELDS.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: { @@ -88,11 +262,8 @@ export async function registerScreenerRoutes(app: FastifyInstance) { const log = req.log.child({ component: 'route:screener' }) try { const q = req.query - const parsed = ScreenerQuerySchema.safeParse(q) if (!parsed.success) { - // market_cap is not a validation error but an unsupported parameter; - // it keeps its own machine-readable code while using the shared shape. const marketCapRejected = parsed.error.issues.some( (issue) => issue.path[0] === 'market_cap' ) @@ -105,139 +276,17 @@ export async function registerScreenerRoutes(app: FastifyInstance) { ) ) } - const { limit, sortBy, order, cursor } = parsed.data - const filters = { - volume: parsed.data.volume, - change_24h_min: parsed.data.change_24h_min, - change_24h_max: parsed.data.change_24h_max, - price_min: parsed.data.price_min, - price_max: parsed.data.price_max, - liquidity: parsed.data.liquidity, - } - - // 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 (cursor) { - try { - const decoded = Buffer.from(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', code: 'VALIDATION_ERROR' }) - } - } - - 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') - } + const result = await getScreenerData({ + ...parsed.data, + network: req.network ?? activeNetwork, + }) - return { - data, - nextCursor, - hasMore, - total: null, + return result + } catch (error: any) { + if (error instanceof ScreenerValidationError) { + return reply.status(400).send({ error: error.message, code: 'VALIDATION_ERROR' }) } - } catch (error) { log.error({ err: error }, 'screener query failed') return reply.status(500).send({ error: 'Internal server error' }) } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..0a6e2286 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,124 +1,457 @@ -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, paidCallAllowlist: ['example.com'] }); + beforeEach(() => { + vi.clearAllMocks(); + }); - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - - // Re-setup handlers to capture the handler - (server as any).setupHandlers(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + describe('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(); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + 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(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, paidCallAllowlist: ['example.com'] }); + 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); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + 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: 'paid_call', - arguments: { - url: 'http://example.com/paid' + 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.pairs).toHaveLength(1); + expect(content.pairs[0].pairKey).toContain('GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + expect(content.total).toBe(1); + expect(content.hasMore).toBe(false); + }); + + it('lens_get_pools returns liquidity reserves and spot prices', async () => { + const mockPools = [ + { + poolId: 'pool-1', + source: 'AMM', + reserveA: 10000, + reserveB: 1500, + spotPrice: 0.15, + feeBps: 30, + }, + ]; + 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: 5, network: 'testnet' }, + }, + }); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.pools).toHaveLength(1); + expect(content.pools[0].spotPrice).toBe(0.15); + expect(content.pools[0].reserveA).toBe(10000); + expect(content.total).toBe(1); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + it('lens_screen_pairs filters and ranks pairs cleanly', async () => { + const mockScreenerResult = { + data: [ + { + pair_key: 'XLM/USDC', + volume: 50000, + price: 0.15, + change_24h: 3.5, + liquidity: 120000, + }, + ], + nextCursor: 'eyJsYXN0VmFsIjoxMjAwMDB9', + 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, network: 'testnet' }, + }, + }); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.data).toHaveLength(1); + expect(content.data[0].volume).toBe(50000); + expect(content.nextCursor).toBe('eyJsYXN0VmFsIjoxMjAwMDB9'); + }); + + it('lens_screen_pairs rejects market_cap parameter with ERR_BAD_REQUEST', async () => { + vi.spyOn(screenerModule, 'getScreenerData').mockRejectedValue( + new screenerModule.ScreenerValidationError( + 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' + ) + ); + + 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 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)', () => { + const publicLookup = { allowedHosts: [], lookup: async () => ['93.184.216.34'] }; + + 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({ bazaarFetch: publicLookup }); + 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, paidCallAllowlist: ['example.com'] }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + it('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 response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const server = new BazaarMcpServer({ bazaarFetch: publicLookup }); + 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({ bazaarFetch: publicLookup }); + 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, paidCallAllowlist: ['example.com'] }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + 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'); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const mockFetch = vi + .fn() + .mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; } - } + }; + (server as any).setupHandlers(); + + 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'); }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => 'Payment Required', + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + 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'); + }); + + it('should return successfully on a successful call', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: 'hello' }), + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + + 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(); + }); }); });