Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions clients/typescript/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never>;
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
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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"];
};
};
}
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
67 changes: 37 additions & 30 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}`);
Expand All @@ -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...');
Expand Down
15 changes: 15 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2992,6 +2992,21 @@
}
}
}
},
"BadRequest": {
"description": "Bad request or invalid parameters",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
"headers": {
Expand Down
9 changes: 9 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand Down
Loading
Loading