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'); + } + }); +}); +