From 8ca668fbecd99981eabb06dcd6a582bde74a1028 Mon Sep 17 00:00:00 2001 From: Ahmad12developer Date: Mon, 19 Oct 2026 14:34:16 +0100 Subject: [PATCH 1/2] fix(mcp): make mainnet key optional and explicit opt-in (closes #412) --- examples/mcp-server/README.md | 27 +++---- examples/mcp-server/run.ts | 51 ++++++------ src/mcp/paymentConfig.ts | 103 ++++++++++++++++++++++++ src/mcp/server.ts | 20 ++++- tests/mcp.test.ts | 147 ++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 44 deletions(-) create mode 100644 src/mcp/paymentConfig.ts diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index bada952f..89c7affc 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -26,25 +26,23 @@ paid call from the mainnet account. | 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_AGENT_SECRET_KEY_TESTNET` | No | Stellar secret key used to sign payments on `stellar:testnet`. Only this key is needed to try payment tools on testnet. | +| `MCP_AGENT_SECRET_KEY_MAINNET` | No | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money**. Only active when `MCP_ENABLE_MAINNET=true`. | +| `MCP_ENABLE_MAINNET` | No | Set to `true` to opt in to signing mainnet payments with real funds. | | `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | +| `MCP_PAID_CALL_ALLOWED_HOSTS` | No | Comma-separated allowlist of origins `paid_call` may pay. | -Both keys are required: `run.ts` exits with an error if either is unset. - -The server talks to two Stellar networks: -- `stellar:testnet` — safe, friendbot-funded play money -- `stellar:pubnet` — mainnet, real XLM +The server starts safely under any configuration: +- **Testnet only** (recommended): Set `MCP_AGENT_SECRET_KEY_TESTNET`. The server signs only on testnet. +- **Mainnet opt-in**: Requires both `MCP_AGENT_SECRET_KEY_MAINNET` and `MCP_ENABLE_MAINNET=true`. The server logs an explicit warning at startup that real funds are reachable. +- **Read-only (no keys)**: The server still starts, allowing read-only tools like `bazaar_search` and `lens_get_price` to function. `paid_call` refuses calls with a structured error. ## Safe way to try it first -Never test with a funded mainnet key. Instead: - 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. - -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. +2. Leave `MCP_AGENT_SECRET_KEY_MAINNET` unset. You do **not** need a mainnet key to run on testnet. +3. If you ever test on mainnet, ensure you explicitly opt in by setting `MCP_ENABLE_MAINNET=true`. +4. Leave `MCP_MAX_PAYMENT_PRICE` at its default `$1.00`, or lower it. Never log, echo, print, or commit a real secret key. This README shows env var names only; it never shows a key value. @@ -54,13 +52,12 @@ Prerequisites: - 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) +- Secret-key env var set for testnet (see "Safe way to try it first" above) Run the server in one terminal: ```bash export MCP_AGENT_SECRET_KEY_TESTNET= -export MCP_AGENT_SECRET_KEY_MAINNET= npm run mcp:server ``` diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index a275fbf2..d001d9d3 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,38 +1,22 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; import { createLensPriceQueries } from '../../src/mcp/priceQueries'; +import { setupMcpPayment } from '../../src/mcp/paymentConfig'; import { activeNetwork } from '../../src/config'; -import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; -import { ExactStellarScheme } from '@x402/stellar/exact/client'; -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_ALLOW_MAINNET === 'true' || + process.argv.includes('--allow-mainnet'); - 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, { + const { fetchWithPayment, activeNetworks, warnings, info } = setupMcpPayment({ + testnetKey, + mainnetKey, + enableMainnet, maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', - schemes: [ - { - network: 'stellar:testnet', - client: testnetClient, - }, - { - network: 'stellar:pubnet', - client: pubnetClient, - } - ] }); // paid_call spends from the keys above, so the hosts it may pay for are the @@ -47,11 +31,24 @@ async function main() { paidCallAllowlist, // Lens's own aggregated prices, read from the same Postgres the API serves. priceQueries: createLensPriceQueries(), - paidCallAllowlist }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); - console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + + for (const item of info) { + console.error(`[mcp-server] ${item}`); + } + + for (const warning of warnings) { + console.error(`[mcp-server] ${warning}`); + } + + if (activeNetworks.length > 0) { + console.error(`[mcp-server] Agent configured for: ${activeNetworks.join(', ')}`); + } else { + console.error('[mcp-server] Running without payment keys. Only read-only operations are permitted.'); + } + console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); console.error( paidCallAllowlist.length > 0 diff --git a/src/mcp/paymentConfig.ts b/src/mcp/paymentConfig.ts new file mode 100644 index 00000000..6b24fce7 --- /dev/null +++ b/src/mcp/paymentConfig.ts @@ -0,0 +1,103 @@ +import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +import { ExactStellarScheme } from '@x402/stellar/exact/client'; +import { createEd25519Signer } from '@x402/stellar'; + +export type SupportedStellarNetwork = 'stellar:testnet' | 'stellar:pubnet'; + +export interface McpPaymentEnvOptions { + testnetKey?: string; + mainnetKey?: string; + enableMainnet?: boolean; + maxPrice?: string; + fetchImpl?: typeof fetch; +} + +export interface McpPaymentSetupResult { + fetchWithPayment: typeof fetch; + activeNetworks: SupportedStellarNetwork[]; + mainnetEnabled: boolean; + mainnetKeyPresent: boolean; + warnings: string[]; + info: string[]; +} + +/** + * Validates configured secret keys and constructs the x402 payment-wrapped fetch. + * + * Rules: + * 1. Testnet key only is fully supported and recommended for development. + * 2. Mainnet key requires explicit opt-in (enableMainnet: true); without it, mainnet is disabled. + * 3. With no keys, returns a fetch instance configured with empty schemes, allowing read-only + * operations while refusing paid calls. + * 4. Never logs or leaks secret key values. + */ +export function setupMcpPayment(options: McpPaymentEnvOptions = {}): McpPaymentSetupResult { + const testnetKey = options.testnetKey?.trim(); + const mainnetKey = options.mainnetKey?.trim(); + const enableMainnet = Boolean(options.enableMainnet); + + const schemes: Array<{ network: SupportedStellarNetwork; client: any }> = []; + const activeNetworks: SupportedStellarNetwork[] = []; + const warnings: string[] = []; + const info: string[] = []; + + // Configure testnet + if (testnetKey) { + try { + const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); + const testnetClient = new ExactStellarScheme(testnetSigner); + schemes.push({ + network: 'stellar:testnet', + client: testnetClient, + }); + activeNetworks.push('stellar:testnet'); + } catch (err: any) { + // Redact any potential key leakage in error messages + const sanitized = String(err.message || err).replace(/S[A-Z0-9]{55}/g, '[REDACTED]'); + throw new Error(`Failed to configure signer for network stellar:testnet: ${sanitized}`); + } + } + + // Configure mainnet (opt-in) + const mainnetKeyPresent = Boolean(mainnetKey); + if (mainnetKey) { + if (enableMainnet) { + try { + const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); + const pubnetClient = new ExactStellarScheme(pubnetSigner); + schemes.push({ + network: 'stellar:pubnet', + client: pubnetClient, + }); + activeNetworks.push('stellar:pubnet'); + warnings.push('⚠️ WARNING: Mainnet (stellar:pubnet) is enabled! Real funds are reachable.'); + } catch (err: any) { + const sanitized = String(err.message || err).replace(/S[A-Z0-9]{55}/g, '[REDACTED]'); + throw new Error(`Failed to configure signer for network stellar:pubnet: ${sanitized}`); + } + } else { + info.push( + 'Mainnet key detected but mainnet is not enabled. Set MCP_ENABLE_MAINNET=true to opt in to real funds.' + ); + } + } + + if (schemes.length === 0) { + info.push('No payment signing keys configured. Running in read-only mode; paid_call will refuse.'); + } + + const baseFetch = options.fetchImpl || globalThis.fetch; + const fetchWithPayment = wrapFetchWithPaymentFromConfig(baseFetch, { + maxPrice: options.maxPrice || '$1.00', + schemes, + }); + + return { + fetchWithPayment, + activeNetworks, + mainnetEnabled: enableMainnet && mainnetKeyPresent, + mainnetKeyPresent, + warnings, + info, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6f311f0c..a7b001ed 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -9,7 +9,7 @@ import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; 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 @@ -32,7 +32,7 @@ export interface McpServerConfig { export class BazaarMcpServer { private server: Server; - private fetchWithPayment: typeof fetch; + private fetchWithPayment?: typeof fetch; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; @@ -192,6 +192,13 @@ export class BazaarMcpServer { } if (request.params.name === 'paid_call') { + if (!this.fetchWithPayment) { + return this.createErrorResponse( + 'ERR_PAYMENT_REJECTED', + 'paid_call is disabled: no payment keys configured on this server.' + ); + } + const url = String(request.params.arguments?.url); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; @@ -247,6 +254,15 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); } + if ( + msg.includes('no network/scheme registered') || + msg.includes('no scheme registered') || + msg.includes('missing key') || + msg.includes('not configured for network') + ) { + return this.createErrorResponse('ERR_PAYMENT_REJECTED', e.message || 'No payment signer configured for the required network.'); + } + if (msg.includes('fetch') || msg.includes('network')) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..799c9152 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -200,3 +200,150 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { expect(JSON.parse(response.content[0].text).resources[0].name).toBe('x'); }); }); + +describe('BazaarMcpServer network & payment key configuration (#412)', () => { + function captureHandler(server: any) { + let handler: any = null; + server.server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + server.setupHandlers(); + return handler; + } + + it('starts successfully with only a testnet key configured', async () => { + const { Keypair } = await import('@stellar/stellar-sdk'); + const testnetSecret = Keypair.random().secret(); + + const { setupMcpPayment } = await import('../src/mcp/paymentConfig.js'); + const payment = setupMcpPayment({ + testnetKey: testnetSecret, + }); + + expect(payment.activeNetworks).toEqual(['stellar:testnet']); + expect(payment.mainnetEnabled).toBe(false); + expect(payment.warnings).toHaveLength(0); + + const server = new BazaarMcpServer({ + fetchWithPayment: payment.fetchWithPayment, + paidCallAllowlist: ['example.com'], + }); + + expect(server).toBeDefined(); + }); + + it('requires explicit opt-in for mainnet even when mainnet key is present', async () => { + const { Keypair } = await import('@stellar/stellar-sdk'); + const testnetSecret = Keypair.random().secret(); + const mainnetSecret = Keypair.random().secret(); + + const { setupMcpPayment } = await import('../src/mcp/paymentConfig.js'); + + // Without enableMainnet + const withoutOptIn = setupMcpPayment({ + testnetKey: testnetSecret, + mainnetKey: mainnetSecret, + enableMainnet: false, + }); + + expect(withoutOptIn.activeNetworks).toEqual(['stellar:testnet']); + expect(withoutOptIn.mainnetEnabled).toBe(false); + expect(withoutOptIn.info.some(msg => msg.includes('MCP_ENABLE_MAINNET=true'))).toBe(true); + expect(withoutOptIn.warnings.some(msg => msg.includes('WARNING'))).toBe(false); + + // With explicit enableMainnet + const withOptIn = setupMcpPayment({ + testnetKey: testnetSecret, + mainnetKey: mainnetSecret, + enableMainnet: true, + }); + + expect(withOptIn.activeNetworks).toEqual(['stellar:testnet', 'stellar:pubnet']); + expect(withOptIn.mainnetEnabled).toBe(true); + expect(withOptIn.warnings.some(msg => msg.includes('WARNING: Mainnet (stellar:pubnet) is enabled!'))).toBe(true); + }); + + it('starts cleanly with no keys at all and refuses paid_call', async () => { + const { setupMcpPayment } = await import('../src/mcp/paymentConfig.js'); + const payment = setupMcpPayment({}); + + expect(payment.activeNetworks).toHaveLength(0); + expect(payment.mainnetEnabled).toBe(false); + expect(payment.info.some(msg => msg.includes('read-only mode'))).toBe(true); + + // 1. Test when server is constructed with setupMcpPayment's fetchWithPayment (schemes: []) + const serverWithEmptySchemes = new BazaarMcpServer({ + fetchWithPayment: payment.fetchWithPayment, + paidCallAllowlist: ['example.com'], + }); + const handler1 = captureHandler(serverWithEmptySchemes); + + // Mock response simulating a 402 challenge demanding testnet + const mock402Response = new Response( + JSON.stringify({ error: 'Payment required' }), + { + status: 402, + headers: { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'L402 network="stellar:testnet"', + }, + } + ); + const mockFetch402 = vi.fn().mockResolvedValue(mock402Response); + (serverWithEmptySchemes as any).fetchWithPayment = mockFetch402; + + // 2. Test when server is constructed without fetchWithPayment + const readOnlyServer = new BazaarMcpServer({ + paidCallAllowlist: ['example.com'], + }); + const handler2 = captureHandler(readOnlyServer); + + const response = await handler2({ + params: { + name: 'paid_call', + arguments: { url: 'http://example.com/paid' }, + }, + }); + + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.code).toBe('ERR_PAYMENT_REJECTED'); + expect(body.reason).toMatch(/no payment keys configured/i); + }); + + it('names the missing network in the error response when a payment requires an unconfigured network', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn().mockRejectedValue( + new Error('No network/scheme registered for stellar:pubnet') + ), + paidCallAllowlist: ['example.com'], + }); + + const handler = captureHandler(server); + const response = await handler({ + params: { + name: 'paid_call', + arguments: { url: 'http://example.com/paid' }, + }, + }); + + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.code).toBe('ERR_PAYMENT_REJECTED'); + expect(body.reason).toContain('stellar:pubnet'); + }); + + it('never logs or leaks secret key values on invalid key errors', async () => { + const fakeKey = 'SINVALIDSECRETKEYTHATMUSTNEVERAPPEARINTHELOGSOROUTPUT12345'; + const { setupMcpPayment } = await import('../src/mcp/paymentConfig.js'); + + try { + setupMcpPayment({ testnetKey: fakeKey }); + expect.unreachable('Should have thrown on invalid secret key'); + } catch (err: any) { + expect(err.message).not.toContain(fakeKey); + expect(err.message).toContain('stellar:testnet'); + } + }); +}); + From 16f4f27c46e330850e3b0de5a4f371d93fb56243 Mon Sep 17 00:00:00 2001 From: esanbasirat <157749237+esanbasirat@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:32:11 +0100 Subject: [PATCH 2/2] fix(mcp): resolve schema generation, openapi BadRequest ref, and merge syntax errors --- .env.example | 4 ++ clients/typescript/src/schema.d.ts | 92 ++++++++++++++++++++++++++++++ examples/mcp-server/run.ts | 58 ++++++------------- openapi.json | 15 +++++ openapi.yaml | 9 +++ src/mcp/codes.ts | 8 +++ src/mcp/paymentConfig.ts | 2 + src/mcp/server.ts | 20 ++----- 8 files changed, 152 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index bd073ffd..bfd7344d 100644 --- a/.env.example +++ b/.env.example @@ -452,6 +452,10 @@ MCP_AGENT_SECRET_KEY_TESTNET= # and can spend real funds. Keep this out of any other process and fund only # what you are willing to lose. MCP_AGENT_SECRET_KEY_MAINNET= +# Explicit opt-in required to enable mainnet payment signing (even if MCP_AGENT_SECRET_KEY_MAINNET is set): +MCP_ENABLE_MAINNET=false +# Alias for MCP_ENABLE_MAINNET: +MCP_ALLOW_MAINNET=false # Transport to serve: "stdio" (default) or "http". stdio speaks over the process's # stdin/stdout and only works for a client that can launch this process on the # same machine; "http" serves Streamable HTTP so remote/hosted clients can connect. diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 6b07abba..c9e32394 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -705,10 +705,60 @@ export interface paths { patch?: never; trace?: never; }; + "/rates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query latest lending and borrow rates across pools + * @description Returns the latest supply and borrow rates, utilisation, and reserves for lending markets. + */ + get: operations["getLendingRates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + LendingRateEntry: { + id: string; + /** @example blend */ + venue: string; + /** @example blend */ + source: string; + /** @example mainnet */ + network: string; + poolId: string; + poolName?: string | null; + /** @example USDC */ + asset: string; + assetAddress?: string | null; + supplyApy: number; + borrowApy: number; + supplyApr?: number | null; + borrowApr?: number | null; + /** @description Borrowed / supplied as a fraction. Null for venues that publish rates only. */ + utilization?: number | null; + totalSupply?: number | null; + totalBorrowed?: number | null; + reserves?: number | null; + ledger?: number; + /** Format: date-time */ + timestamp: string; + }; + LendingRatesResponse: { + network: string; + rates: components["schemas"]["LendingRateEntry"][]; + }; InternalErrorResponse: { /** * @example internal_error @@ -1502,6 +1552,17 @@ export interface components { "application/json": components["schemas"]["InternalErrorResponse"]; }; }; + /** @description Bad request or invalid parameters */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error?: string; + }; + }; + }; }; parameters: { /** @description Formatting for decimal quantities (prices, volumes, reserves). Set to `string` to serialize high-precision `Decimal(36,18)` quantities as exact strings, preventing IEEE-754 floating-point precision loss. Defaults to `number`. Can also be negotiated via `Accept: application/json; decimals=strings` or query parameter `?decimals=strings`. */ @@ -3118,4 +3179,35 @@ export interface operations { 402: components["responses"]["PaymentRequired"]; }; }; + getLendingRates: { + parameters: { + query?: { + /** @description Filter by asset symbol (e.g. USDC, XLM) */ + asset?: string; + /** @description Filter by pool contract ID */ + poolId?: string; + /** @description Stellar network to query (testnet or mainnet) */ + network?: "testnet" | "mainnet"; + /** @description Maximum number of rate records to return */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Latest lending rates */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LendingRatesResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; } diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 81325916..255509a4 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,8 +1,6 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; -import { createLensPriceQueries } from '../../src/mcp/priceQueries'; import { setupMcpPayment } from '../../src/mcp/paymentConfig'; -import { activeNetwork } from '../../src/config'; import { DEFAULT_MCP_METRICS_PATH, DEFAULT_MCP_METRIC_NETWORKS, @@ -12,9 +10,6 @@ import { } from '../../src/mcp/config'; import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; -import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; -import { ExactStellarScheme } from '@x402/stellar/exact/client'; -import { createEd25519Signer } from '@x402/stellar'; function formatHttpUrl(host: string, port: number, path: string): string { const hostPart = host.includes(':') ? `[${host}]` : host; @@ -58,17 +53,6 @@ async function main() { process.env.MCP_ALLOW_MAINNET === 'true' || process.argv.includes('--allow-mainnet'); - const { fetchWithPayment, activeNetworks, warnings, info } = setupMcpPayment({ - testnetKey, - mainnetKey, - enableMainnet, - maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', - const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); - const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); - - const testnetClient = new ExactStellarScheme(testnetSigner); - const pubnetClient = new ExactStellarScheme(pubnetSigner); - // A single payment is capped by spendControls.maxAmountPerPayment. The value is // passed through to the budget so the cumulative ceiling can reserve each call at // its worst case; leaving spendControls unset would let the library default of $1 @@ -88,20 +72,12 @@ async function main() { metricNetworks: labelNetworks, }); - const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { - spendControls: { - maxAmountPerPayment: maxPrice, - }, - schemes: [ - { - network: 'stellar:testnet', - client: testnetClient, - }, - { - network: 'stellar:pubnet', - client: pubnetClient, - } - ] + const { fetchWithPayment, activeNetworks, warnings, info } = setupMcpPayment({ + testnetKey, + mainnetKey, + enableMainnet, + maxPrice, + fetchImpl: paymentLedger.instrumentFetch(globalThis.fetch), }); // The running total outlives the process: this server signs with a real key, and a @@ -113,15 +89,18 @@ async function main() { stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), }); + const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '') + .split(',') + .map(entry => entry.trim()) + .filter(Boolean); + const server = new BazaarMcpServer({ fetchWithPayment, + budget, + paymentLedger, paidCallAllowlist, - // Lens's own aggregated prices, read from the same Postgres the API serves. - priceQueries: createLensPriceQueries(), }); - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); - for (const item of info) { console.error(`[mcp-server] ${item}`); } @@ -136,12 +115,6 @@ async function main() { console.error('[mcp-server] Running without payment keys. Only read-only operations are permitted.'); } - console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); - budget, - paymentLedger - }); - - console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); // The audit file records where money went, never the key that signed it. console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); @@ -153,6 +126,11 @@ async function main() { '[mcp-server] MCP_METRIC_HOSTS is empty, so every spend series is labelled host="unknown". Set it to the nodes this agent pays to get per-host spend.' ); } + console.error( + paidCallAllowlist.length > 0 + ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` + : '[mcp-server] paid_call allow-list is empty: every paid call will be refused. Set MCP_PAID_CALL_ALLOWED_HOSTS to allow hosts.' + ); if (transportConfig.transport === 'http') { console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); diff --git a/openapi.json b/openapi.json index 0c401f5e..8a800303 100644 --- a/openapi.json +++ b/openapi.json @@ -2992,6 +2992,21 @@ } } } + }, + "BadRequest": { + "description": "Bad request or invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } } }, "headers": { diff --git a/openapi.yaml b/openapi.yaml index 72d5778b..841d7afe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2030,6 +2030,15 @@ components: application/json: schema: $ref: '#/components/schemas/InternalErrorResponse' + BadRequest: + description: Bad request or invalid parameters + content: + application/json: + schema: + type: object + properties: + error: + type: string headers: CacheControl: diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index 4f9f185f..41efe333 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -165,6 +165,14 @@ function classifyByText( message: string ): { code: McpErrorCode; signal: string } | null { const msg = message.toLowerCase(); + if ( + msg.includes('no network/scheme registered') || + msg.includes('no scheme registered') || + msg.includes('missing key') || + msg.includes('not configured for network') + ) { + return { code: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, signal: 'unregistered scheme' }; + } if (msg.includes('insufficient') || msg.includes('underfunded')) { return { code: MCP_ERROR_CODES.ERR_INSUFFICIENT_BALANCE, signal: 'insufficient' }; } diff --git a/src/mcp/paymentConfig.ts b/src/mcp/paymentConfig.ts index 6b24fce7..0da4e946 100644 --- a/src/mcp/paymentConfig.ts +++ b/src/mcp/paymentConfig.ts @@ -1,4 +1,6 @@ +// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +// @ts-ignore import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cab6134b..320a03a5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -586,8 +586,6 @@ export class BazaarMcpServer { ); } - const url = String(request.params.arguments?.url); - const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; const parsed = PAID_CALL_ARGUMENTS_SCHEMA.safeParse(request.params.arguments ?? {}); if (!parsed.success) { return this.createErrorResponse('ERR_BAD_REQUEST', formatZodError(parsed.error)); @@ -776,20 +774,6 @@ export class BazaarMcpServer { } } - if ( - msg.includes('no network/scheme registered') || - msg.includes('no scheme registered') || - msg.includes('missing key') || - msg.includes('not configured for network') - ) { - return this.createErrorResponse('ERR_PAYMENT_REJECTED', e.message || 'No payment signer configured for the required network.'); - } - - if (msg.includes('fetch') || msg.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.'); /** * True when an error proves the payment never settled: the transport failed * before offering payment, or the wallet could not fund or submit it. A @@ -798,6 +782,10 @@ export class BazaarMcpServer { private paymentCouldNotHaveSettled(e: any): boolean { const msg = String(e?.message || e).toLowerCase(); return ( + msg.includes('no network/scheme registered') || + msg.includes('no scheme registered') || + msg.includes('missing key') || + msg.includes('not configured for network') || msg.includes('failed to parse payment requirements') || msg.includes('failed to create payment payload') || msg.includes('signatureexpirationledger') ||