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/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 ba3297a6..255509a4 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +import { setupMcpPayment } from '../../src/mcp/paymentConfig'; import { DEFAULT_MCP_METRICS_PATH, DEFAULT_MCP_METRIC_NETWORKS, @@ -9,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; @@ -50,17 +48,10 @@ 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 testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet'); - const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet'); - - const testnetClient = new ExactStellarScheme(testnetSigner); - const pubnetClient = new ExactStellarScheme(pubnetSigner); + const enableMainnet = + process.env.MCP_ENABLE_MAINNET === 'true' || + process.env.MCP_ALLOW_MAINNET === 'true' || + process.argv.includes('--allow-mainnet'); // 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 @@ -81,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 @@ -106,13 +89,32 @@ 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 + paymentLedger, + paidCallAllowlist, }); - 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] 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}`); @@ -124,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 new file mode 100644 index 00000000..0da4e946 --- /dev/null +++ b/src/mcp/paymentConfig.ts @@ -0,0 +1,105 @@ +// @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'; + +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 4e3ead34..320a03a5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -93,7 +93,7 @@ export const VALID_TOOL_NAMES = ['bazaar_search', 'paid_call'] as const; export type ValidToolName = (typeof VALID_TOOL_NAMES)[number]; export interface McpServerConfig { - fetchWithPayment: typeof fetch; + fetchWithPayment?: typeof fetch; /** * Cumulative ceiling over paid calls. Optional so a server can still run with * per-call limits only, but leaving it out means nothing bounds a day: the @@ -269,7 +269,7 @@ function readJsonBody(req: IncomingMessage): Promise { export class BazaarMcpServer { private server: Server; - private fetchWithPayment: typeof fetch; + private fetchWithPayment?: typeof fetch; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; @@ -579,6 +579,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 parsed = PAID_CALL_ARGUMENTS_SCHEMA.safeParse(request.params.arguments ?? {}); if (!parsed.success) { return this.createErrorResponse('ERR_BAD_REQUEST', formatZodError(parsed.error)); @@ -775,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') || diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 1cdd791d..30f208f6 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -292,6 +292,152 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { }); }); +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'); + } + }); +}); + describe('BazaarMcpServer argument validation (#411)', () => { function captureHandlers(server: any) { let callHandler: any = null;