Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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=<your-testnet-secret-key>
export MCP_AGENT_SECRET_KEY_MAINNET=<your-empty-mainnet-secret-key>
npm run mcp:server
```

Expand Down
51 changes: 24 additions & 27 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
103 changes: 103 additions & 0 deletions src/mcp/paymentConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
20 changes: 18 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down
Loading