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
11 changes: 11 additions & 0 deletions .changeset/mcp-spend-cap-and-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"lens": minor
---

Enforce cumulative spend ceilings and durable payment audit ledger in Bazaar MCP server:
- Added `ERR_SPEND_LIMIT_EXCEEDED` error code to `MCP_ERROR_CODES` when cumulative spend ceiling is reached.
- Cumulative spend ceiling (`maxCumulativeSpend`, env `MCP_MAX_CUMULATIVE_SPEND`) alongside per-call limit (`maxPrice`).
- Disclosed real-time remaining budget and spending limits dynamically in `paid_call` tool description and via `bazaar_spend_budget` query tool.
- Introduced `SpendLedger` recording every payment attempt (timestamp, URL, host, method, amount, network, transaction hash, outcome, and reason) with zero secret material recorded.
- Configurable persistence via `spendLedgerPath` (env `MCP_SPEND_LEDGER_PATH`) to restore running spend total across server restarts, or ephemeral in-memory session tracking.
- Exported Prometheus metrics `mcp_payment_attempts_total` and `mcp_spend_total` with bounded host labels against an allowlist to prevent cardinality explosion.
4 changes: 3 additions & 1 deletion examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ async function main() {
paidCallAllowlist,
// Lens's own aggregated prices, read from the same Postgres the API serves.
priceQueries: createLensPriceQueries(),
paidCallAllowlist
maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00',
maxCumulativeSpend: process.env.MCP_MAX_CUMULATIVE_SPEND || '$10.00',
spendLedgerPath: process.env.MCP_SPEND_LEDGER_PATH,
});

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
Expand Down
1 change: 1 addition & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const MCP_ERROR_CODES = {
ERR_EXPIRED_AUTHORISATION: 'ERR_EXPIRED_AUTHORISATION',
ERR_MISSING_TRUSTLINE: 'ERR_MISSING_TRUSTLINE',
ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE',
ERR_SPEND_LIMIT_EXCEEDED: 'ERR_SPEND_LIMIT_EXCEEDED',
ERR_NO_RESULTS: 'ERR_NO_RESULTS',
ERR_BAD_REQUEST: 'ERR_BAD_REQUEST',
ERR_INVALID_ASSET: 'ERR_INVALID_ASSET',
Expand Down
212 changes: 212 additions & 0 deletions src/mcp/ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import fs from 'fs';
import path from 'path';

/**
* A safe, durable audit record of a payment attempt.
*
* NOTE: Never records secret keys, seeds, signatures, or raw signed envelopes.
*/
export interface PaymentRecord {
id: string;
timestamp: string;
url: string;
host: string;
method: string;
amount: number;
network: string;
transactionHash?: string | null;
outcome: 'success' | 'failed';
reason?: string;
}

export interface SpendLedgerConfig {
maxPrice: number;
maxCumulativeSpend: number;
spendLedgerPath?: string;
window?: 'session' | 'daily';
defaultCallCost?: number;
}

/**
* Parse currency strings like "$1.00", "0.50", or numbers into numeric values.
*/
export function parseCurrencyAmount(val: string | number | undefined, defaultValue: number): number {
if (val === undefined || val === null) return defaultValue;
if (typeof val === 'number') return isNaN(val) ? defaultValue : val;
const cleaned = String(val).replace(/[^0-9.-]/g, '');
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? defaultValue : parsed;
}

/**
* SpendLedger tracks agent payment attempts, cumulative spending, and remaining budget.
*
* Restart Behavior:
* - When `spendLedgerPath` is configured, records and cumulative totals are loaded from disk
* on startup and appended to the ledger file on every payment attempt. This restores the
* running total across server restarts so cumulative limits cannot be bypassed by restarting.
* - When `spendLedgerPath` is omitted (default), spending is tracked in-memory for the lifetime
* of the MCP server process session. This allows ephemeral agent runs or serverless deployments
* without mandatory disk storage.
*/
export class SpendLedger {
public readonly maxPrice: number;
public readonly maxCumulativeSpend: number;
public readonly spendLedgerPath?: string;
public readonly window: 'session' | 'daily';
public readonly defaultCallCost: number;

private records: PaymentRecord[] = [];
private sessionRunningTotal: number = 0;

constructor(config: SpendLedgerConfig) {
this.maxPrice = config.maxPrice;
this.maxCumulativeSpend = config.maxCumulativeSpend;
this.spendLedgerPath = config.spendLedgerPath;
this.window = config.window || 'session';
this.defaultCallCost = config.defaultCallCost ?? 1.0;

this.loadPersistedRecords();
}

private loadPersistedRecords(): void {
if (!this.spendLedgerPath) return;

try {
if (fs.existsSync(this.spendLedgerPath)) {
const raw = fs.readFileSync(this.spendLedgerPath, 'utf-8').trim();
if (raw) {
try {
// Support JSON array or newline-delimited JSON
if (raw.startsWith('[')) {
this.records = JSON.parse(raw);
} else {
this.records = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
} catch {
this.records = [];
}
}
}
} catch {
// Deliberate fallback to empty in case of unreadable file
this.records = [];
}

this.recalculateRunningTotal();
}

private recalculateRunningTotal(): void {
const cutoff = this.window === 'daily' ? Date.now() - 24 * 60 * 60 * 1000 : 0;
this.sessionRunningTotal = this.records
.filter((r) => r.outcome === 'success' && (cutoff === 0 || new Date(r.timestamp).getTime() >= cutoff))
.reduce((sum, r) => sum + (r.amount || 0), 0);
}

private persistRecord(record: PaymentRecord): void {
if (!this.spendLedgerPath) return;

try {
const dir = path.dirname(path.resolve(this.spendLedgerPath));
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Append as newline-delimited JSON line
fs.appendFileSync(this.spendLedgerPath, JSON.stringify(record) + '\n', 'utf-8');
} catch {
// Disk write failure must not crash payment processing
}
}

/**
* Record a payment attempt (successful or failed).
*/
public recordAttempt(params: {
url: string;
host: string;
method: string;
amount: number;
network: string;
transactionHash?: string | null;
outcome: 'success' | 'failed';
reason?: string;
}): PaymentRecord {
const record: PaymentRecord = {
id: `pay_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
timestamp: new Date().toISOString(),
url: params.url,
host: params.host,
method: params.method.toUpperCase(),
amount: Number(params.amount.toFixed(4)),
network: params.network,
transactionHash: params.transactionHash || null,
outcome: params.outcome,
reason: params.reason,
};

this.records.push(record);

if (record.outcome === 'success') {
this.sessionRunningTotal += record.amount;
}

this.persistRecord(record);
return record;
}

/**
* Get the current running total of successful spend.
*/
public getRunningTotal(): number {
return Number(this.sessionRunningTotal.toFixed(4));
}

/**
* Get the remaining budget before hitting maxCumulativeSpend.
*/
public getRemainingBudget(): number {
return Math.max(0, Number((this.maxCumulativeSpend - this.sessionRunningTotal).toFixed(4)));
}

/**
* Returns whether a proposed payment amount is allowed within both single-call
* and cumulative spend limits.
*/
public checkSpendAllowance(amount: number): { allowed: boolean; reason?: string } {
if (amount > this.maxPrice) {
return {
allowed: false,
reason: `Payment amount $${amount.toFixed(2)} exceeds per-call limit of $${this.maxPrice.toFixed(2)}.`,
};
}

const proposedTotal = this.sessionRunningTotal + amount;
if (proposedTotal > this.maxCumulativeSpend || this.getRemainingBudget() <= 0) {
return {
allowed: false,
reason: `Cumulative spend limit of $${this.maxCumulativeSpend.toFixed(2)} reached. Current spent: $${this.getRunningTotal().toFixed(2)}. Remaining budget: $${this.getRemainingBudget().toFixed(2)}.`,
};
}

return { allowed: true };
}

/**
* Retrieve read-only copy of payment audit records.
*/
public getRecords(): ReadonlyArray<PaymentRecord> {
return [...this.records];
}

/**
* Clear in-memory records (useful for test resets).
*/
public clear(): void {
this.records = [];
this.sessionRunningTotal = 0;
}
}
96 changes: 96 additions & 0 deletions src/mcp/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Counter, Registry } from 'prom-client';
import { register as defaultRegister } from '../metrics.js';

export interface McpMetricsConfig {
registry?: Registry;
allowedHosts?: string[];
}

/**
* Prometheus metrics for MCP agent paid calls.
*
* To prevent Prometheus cardinality explosion, `host` is strictly bounded to an allow-listed
* host set. Any unlisted target host is mapped to 'other'. Network is bounded to known values.
*/
export class McpMetrics {
public readonly paymentAttemptsTotal: Counter<string>;
public readonly spendTotal: Counter<string>;
private readonly allowedHosts: Set<string>;

constructor(config?: McpMetricsConfig) {
const reg = config?.registry || defaultRegister;
this.allowedHosts = new Set(
(config?.allowedHosts || [
'localhost',
'127.0.0.1',
'bazaar.stellar.org',
'api.stellar.org',
'horizon.stellar.org',
'horizon-testnet.stellar.org'
]).map((h) => h.toLowerCase())
);

// Counter for payment attempts (labelled by bounded host, network, and outcome)
const attemptsMetricName = 'mcp_payment_attempts_total';
this.paymentAttemptsTotal =
(reg.getSingleMetric(attemptsMetricName) as Counter<string>) ||
new Counter({
name: attemptsMetricName,
help: 'Total number of payment attempts by MCP agent, labelled by host, network, and outcome',
labelNames: ['host', 'network', 'outcome'],
registers: [reg],
});

// Sum of spend in USD (labelled by bounded host and network)
const spendMetricName = 'mcp_spend_total';
this.spendTotal =
(reg.getSingleMetric(spendMetricName) as Counter<string>) ||
new Counter({
name: spendMetricName,
help: 'Total cumulative amount spent on paid calls by the MCP agent in USD',
labelNames: ['host', 'network'],
registers: [reg],
});
}

/**
* Keep host labels strictly bounded to prevent time series explosion.
*/
public getBoundedHost(urlStr: string): string {
try {
const parsed = new URL(urlStr);
const host = parsed.host.toLowerCase();
if (this.allowedHosts.has(host)) {
return host;
}
return 'other';
} catch {
return 'invalid_url';
}
}

/**
* Bounded network label.
*/
public getBoundedNetwork(net?: string): string {
if (!net) return 'unknown';
const clean = net.toLowerCase();
if (clean.includes('pubnet') || clean.includes('mainnet')) return 'stellar:pubnet';
if (clean.includes('testnet')) return 'stellar:testnet';
return 'other';
}

/**
* Record payment attempt and spend into Prometheus metrics.
*/
public recordPayment(url: string, network: string, outcome: 'success' | 'failed', amount: number): void {
const host = this.getBoundedHost(url);
const boundedNet = this.getBoundedNetwork(network);

this.paymentAttemptsTotal.inc({ host, network: boundedNet, outcome });

if (outcome === 'success' && amount > 0) {
this.spendTotal.inc({ host, network: boundedNet }, amount);
}
}
}
Loading
Loading