From b45b7bb50689fd15fa413acfd91b0e25ea36038a Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 16:17:31 +0530 Subject: [PATCH] feat(mcp): harden keys, env drift guard, rate limiting, and resources (#412, #413, #417, #418) --- .changeset/mcp-hardening-and-resources.md | 9 + .env.example | 32 ++ examples/mcp-server/run.ts | 86 ++-- src/__tests__/envDrift.test.ts | 28 +- src/mcp/codes.ts | 5 +- src/mcp/server.ts | 430 +++++++++++++++---- src/metrics.ts | 8 + tests/mcp.test.ts | 489 +++++++++++++++++----- 8 files changed, 890 insertions(+), 197 deletions(-) create mode 100644 .changeset/mcp-hardening-and-resources.md diff --git a/.changeset/mcp-hardening-and-resources.md b/.changeset/mcp-hardening-and-resources.md new file mode 100644 index 00000000..93c64fd5 --- /dev/null +++ b/.changeset/mcp-hardening-and-resources.md @@ -0,0 +1,9 @@ +--- +'lens': minor +--- + +Harden MCP server with opt-in mainnet key requirements, extended environment drift guard, per-tool rate limiting and concurrency cap, and MCP resource declarations: +- Decouple testnet execution from mainnet keys in `examples/mcp-server/run.ts`; require explicit opt-in (`MCP_ENABLE_MAINNET=true`) with loud startup warning before enabling `stellar:pubnet` signing, and support read-only mode with no keys (#412). +- Extend `src/__tests__/envDrift.test.ts` to scan `examples/` and `scripts/`, document all MCP server variables and oracle relay variables in `.env.example`, and enforce explicit rationale for undocumented variables (#413). +- Implement per-tool rate limiting and in-flight concurrency capping in `BazaarMcpServer` with structured `ERR_RATE_LIMITED` and `ERR_CONCURRENCY_LIMIT_EXCEEDED` errors and retry hints, tracking rejections via Prometheus counter `mcp_rate_limit_rejections_total` (#417). +- Declare and implement MCP `resources` capability, exposing `lens://pairs`, `lens://venues`, `lens://openapi`, and `lens://status` with accurate MIME types and freshness metadata (#418). 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/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/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..c016b538 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,19 +1,52 @@ 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 } from '../config.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 }, + ...(configObj.rateLimits ?? {}), + }; + this.openapiPath = configObj.openapiPath; - constructor(config: McpServerConfig) { - this.fetchWithPayment = config.fetchWithPayment; this.server = new Server( { name: 'bazaar-mcp-server', @@ -22,6 +55,7 @@ export class BazaarMcpServer { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -29,6 +63,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: [ @@ -72,100 +149,313 @@ export class BazaarMcpServer { })); 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 - ? `${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 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; + + try { + 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'); + } + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); } - - 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.' + ); + } + + 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 { + content: [ + { type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }, + ], + }; + } catch (e: any) { + return this.handlePaidCallError(e); } - - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; - } catch (e: any) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 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 (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 (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 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 { + 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/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..5393a787 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,123 +1,412 @@ -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, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { mcp_rate_limit_rejections_total } from '../src/metrics'; 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('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'); }); - 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', + }, + }, + }); - 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'); + }); + + 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).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + 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); + }); }); - 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('Key Configuration and Network Errors (#412)', () => { + it('refuses paid_call with ERR_MISSING_KEY when server starts without payment keys', async () => { + const server = new BazaarMcpServer({ configuredNetworks: [] }); + + 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_KEY'); + expect(content.reason).toContain('read-only mode'); + }); + + it('maps missing scheme error to ERR_MISSING_KEY naming the required network', async () => { + const mockFetch = vi + .fn() + .mockRejectedValue(new Error('No matching payment scheme found for network stellar:pubnet')); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + configuredNetworks: ['stellar:testnet'], + }); + + 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_KEY'); + expect(content.reason).toContain('stellar:pubnet'); }); - - 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('Rate Limiting & Concurrency Guard (#417)', () => { + it('throttles concurrent requests when exceeding maxConcurrency and increments metric', async () => { + let resolveFirst: () => void; + const firstPromise = new Promise((r) => { + resolveFirst = r; + }); + + const slowFetch = vi.fn().mockImplementation(async () => { + await firstPromise; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }; + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: slowFetch, + maxConcurrency: 1, + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; } - } + }; + (server as any).setupHandlers(); + + // Launch first in-flight call + const firstCall = handler({ + params: { + name: 'paid_call', + arguments: { url: 'http://example.com/1' }, + }, + }); + + // Immediate second call should be rejected with ERR_CONCURRENCY_LIMIT_EXCEEDED + const secondCall = await handler({ + params: { + name: 'paid_call', + arguments: { url: 'http://example.com/2' }, + }, + }); + + expect(secondCall.isError).toBe(true); + const errorContent = JSON.parse(secondCall.content[0].text); + expect(errorContent.code).toBe('ERR_CONCURRENCY_LIMIT_EXCEEDED'); + expect(errorContent.retryAfterSeconds).toBeDefined(); + expect(errorContent.reason).toContain('Retry after'); + + // Finish the first call + resolveFirst!(); + const firstResult = await firstCall; + expect(firstResult.isError).toBeFalsy(); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + it('enforces per-tool rate limits and returns retry hint', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + rateLimits: { + paid_call: { requestsPerMinute: 2 }, + }, + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + // Call 1: OK + const res1 = await handler({ + params: { name: 'paid_call', arguments: { url: 'http://example.com/1' } }, + }); + expect(res1.isError).toBeFalsy(); + + // Call 2: OK + const res2 = await handler({ + params: { name: 'paid_call', arguments: { url: 'http://example.com/2' } }, + }); + expect(res2.isError).toBeFalsy(); + + // Call 3: Exceeded limit + const res3 = await handler({ + params: { name: 'paid_call', arguments: { url: 'http://example.com/3' } }, + }); + expect(res3.isError).toBe(true); + const errorContent = JSON.parse(res3.content[0].text); + expect(errorContent.code).toBe('ERR_RATE_LIMITED'); + expect(errorContent.retryAfterSeconds).toBeGreaterThan(0); + expect(errorContent.reason).toContain('Retry after'); + }); }); - - 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('MCP Resources (#418)', () => { + 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(); + }); + + it('reads lens://venues resource with supported venues', 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://venues' } }); + expect(result.contents).toHaveLength(1); + const item = result.contents[0]; + expect(item.uri).toBe('lens://venues'); + + const parsed = JSON.parse(item.text); + expect(parsed.venues).toBeInstanceOf(Array); + const venueIds = parsed.venues.map((v: any) => v.id); + expect(venueIds).toContain('sdex'); + expect(venueIds).toContain('amm'); + expect(venueIds).toContain('soroswap'); + expect(venueIds).toContain('aquarius'); }); - - 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('reads lens://openapi and lens://status resources', 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 openapiRes = await readHandler({ params: { uri: 'lens://openapi' } }); + expect(openapiRes.contents).toHaveLength(1); + expect(openapiRes.contents[0].uri).toBe('lens://openapi'); + const openapiJson = JSON.parse(openapiRes.contents[0].text); + expect(openapiJson.openapi).toBe('3.0.0'); + + const statusRes = await readHandler({ params: { uri: 'lens://status' } }); + expect(statusRes.contents).toHaveLength(1); + expect(statusRes.contents[0].uri).toBe('lens://status'); + const statusJson = JSON.parse(statusRes.contents[0].text); + expect(statusJson.status).toBe('ok'); }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('throws when requested resource does not exist', 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(); + + await expect(readHandler({ params: { uri: 'lens://nonexistent' } })).rejects.toThrow( + 'Resource not found: lens://nonexistent' + ); + }); }); });