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
9 changes: 9 additions & 0 deletions .changeset/mcp-hardening-and-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'lens': minor
---

Harden MCP server with opt-in mainnet key requirements, extended environment drift guard, per-tool rate limiting and concurrency cap, and MCP resource declarations:
- Decouple testnet execution from mainnet keys in `examples/mcp-server/run.ts`; require explicit opt-in (`MCP_ENABLE_MAINNET=true`) with loud startup warning before enabling `stellar:pubnet` signing, and support read-only mode with no keys (#412).
- Extend `src/__tests__/envDrift.test.ts` to scan `examples/` and `scripts/`, document all MCP server variables and oracle relay variables in `.env.example`, and enforce explicit rationale for undocumented variables (#413).
- Implement per-tool rate limiting and in-flight concurrency capping in `BazaarMcpServer` with structured `ERR_RATE_LIMITED` and `ERR_CONCURRENCY_LIMIT_EXCEEDED` errors and retry hints, tracking rejections via Prometheus counter `mcp_rate_limit_rejections_total` (#417).
- Declare and implement MCP `resources` capability, exposing `lens://pairs`, `lens://venues`, `lens://openapi`, and `lens://status` with accurate MIME types and freshness metadata (#418).
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ ORACLE_RELAY_ASSET_A=XLM
ORACLE_RELAY_ASSET_B=USDC
# How often the relay polls Lens and updates the contract.
ORACLE_RELAY_INTERVAL_MS=60000
# Soroban RPC URL for the oracle contract calls (default: https://soroban-testnet.stellar.org)
ORACLE_RELAY_RPC_URL=https://soroban-testnet.stellar.org
# Stellar network passphrase for the oracle relay (default: testnet passphrase)
ORACLE_RELAY_NETWORK_PASSPHRASE=Test SDF Network ; September 2015


# --- Price Alert Bot Example ---
# Lens WebSocket endpoint the alert bot connects to.
Expand Down Expand Up @@ -225,3 +230,30 @@ MCP_PAID_CALL_ALLOWED_HOSTS=
# against a table that has never been pruned cannot hold a long lock or blow
# the WAL. Default 1000.
SNAPSHOT_PRUNE_BATCH_SIZE=1000

# ─── Model Context Protocol (MCP) Server ───────────────────────────────────
# Stellar secret key used by the MCP agent to sign testnet payments for the paid_call tool.
# Leave empty to run the MCP server in read-only mode on testnet.
MCP_AGENT_SECRET_KEY_TESTNET=

# Stellar secret key used by the MCP agent to sign mainnet payments for the paid_call tool.
# WARNING: Setting this variable allows the MCP agent to spend real funds from this account!
# Mainnet execution is strictly opt-in: setting this variable alone will NOT activate
# mainnet unless MCP_ENABLE_MAINNET=true is also explicitly set.
MCP_AGENT_SECRET_KEY_MAINNET=

# Explicit opt-in flag required to activate stellar:pubnet signing with MCP_AGENT_SECRET_KEY_MAINNET.
# When set to true, logs a clear warning at startup that live funds can be spent. Default: false.
MCP_ENABLE_MAINNET=false

# Maximum price the MCP agent is permitted to pay for a single paid_call (default: $1.00).
MCP_MAX_PAYMENT_PRICE=$1.00

# Maximum number of concurrent in-flight MCP tool calls before throttling (default: 5).
MCP_MAX_CONCURRENCY=5

# Per-tool rate limits in requests per minute for MCP server calls.
# Exceeding these limits returns a structured ERR_RATE_LIMITED error with a retry hint.
MCP_RATE_LIMIT_PAID_CALL=10
MCP_RATE_LIMIT_BAZAAR_SEARCH=30

84 changes: 60 additions & 24 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,49 @@ 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_ENABLE_MAINNET === '1';

const schemes: Array<{ network: string; client: any }> = [];
const configuredNetworks: string[] = [];

if (testnetKey) {
const testnetSigner = createEd25519Signer(testnetKey, 'stellar:testnet');
const testnetClient = new ExactStellarScheme(testnetSigner);
schemes.push({
network: 'stellar:testnet',
client: testnetClient,
});
configuredNetworks.push('stellar:testnet');
}

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, {
maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00',
schemes: [
{
network: 'stellar:testnet',
client: testnetClient,
},
{
if (mainnetKey) {
if (enableMainnet) {
console.error('[mcp-server] WARNING: stellar:pubnet is enabled with live funds!');
const pubnetSigner = createEd25519Signer(mainnetKey, 'stellar:pubnet');
const pubnetClient = new ExactStellarScheme(pubnetSigner);
schemes.push({
network: 'stellar:pubnet',
client: pubnetClient,
}
]
});
});
configuredNetworks.push('stellar:pubnet');
} else {
console.error(
'[mcp-server] WARNING: MCP_AGENT_SECRET_KEY_MAINNET is set, but mainnet is opt-in. Set MCP_ENABLE_MAINNET=true to enable stellar:pubnet.'
);
}
}

if (configuredNetworks.length === 0) {
console.error('[mcp-server] WARNING: No payment keys configured. Server is running in read-only mode.');
}

const fetchWithPayment =
schemes.length > 0
? wrapFetchWithPaymentFromConfig(globalThis.fetch, {
maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00',
schemes,
})
: undefined;

// paid_call spends from the keys above, so the hosts it may pay for are the
// operator's decision. Unset means no host is payable at all (#404).
Expand All @@ -42,11 +60,29 @@ async function main() {

const server = new BazaarMcpServer({
fetchWithPayment,
paidCallAllowlist
paidCallAllowlist,
configuredNetworks,
maxConcurrency: process.env.MCP_MAX_CONCURRENCY ? parseInt(process.env.MCP_MAX_CONCURRENCY, 10) : undefined,
rateLimits: {
paid_call: {
requestsPerMinute: process.env.MCP_RATE_LIMIT_PAID_CALL
? parseInt(process.env.MCP_RATE_LIMIT_PAID_CALL, 10)
: 10,
},
bazaar_search: {
requestsPerMinute: process.env.MCP_RATE_LIMIT_BAZAAR_SEARCH
? parseInt(process.env.MCP_RATE_LIMIT_BAZAAR_SEARCH, 10)
: 30,
},
},
});

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet');
console.error(
`[mcp-server] Agent configured for ${
configuredNetworks.length > 0 ? configuredNetworks.join(' and ') : 'read-only (no payment keys)'
}`
);
console.error(
paidCallAllowlist.length > 0
? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}`
Expand All @@ -56,7 +92,7 @@ async function main() {
await server.run();
}

main().catch(err => {
main().catch((err) => {
console.error('[mcp-server] Fatal error:', err);
process.exit(1);
});
28 changes: 27 additions & 1 deletion src/__tests__/envDrift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import ts from 'typescript'

const rootDir = resolve(__dirname, '../..')
const srcDir = resolve(rootDir, 'src')
const examplesDir = resolve(rootDir, 'examples')
const scriptsDir = resolve(rootDir, 'scripts')
const envExamplePath = resolve(rootDir, '.env.example')


/**
* Explicit allow-list for environment variables that are deliberately
* undocumented in .env.example.
Expand Down Expand Up @@ -156,7 +159,11 @@ describe('.env.example drift guard', () => {
// vitest's 5s default and makes this guard flaky. Give it real headroom.
it('asserts each process.env read in source appears in .env.example or the allow-list', { timeout: 60_000 }, () => {
const documentedVars = parseEnvExample(envExamplePath)
const sourceFiles = getSourceFiles(srcDir)
const sourceFiles = [
...getSourceFiles(srcDir),
...getSourceFiles(examplesDir),
...getSourceFiles(scriptsDir),
]
const allReads: EnvRead[] = []

for (const file of sourceFiles) {
Expand Down Expand Up @@ -204,6 +211,25 @@ describe('.env.example drift guard', () => {
expect(missing[0].name).toBe('SOME_BRAND_NEW_SECRET_VAR')
})

it('fails when an un-documented process.env read is encountered in examples/', () => {
const sampleCode = `
export function runExample() {
const secret = process.env.MCP_SOME_NEW_UNDOCUMENTED_KEY
return secret
}
`
const reads = extractEnvReadsFromSource(sampleCode, 'examples/mcp-server/sample.ts')
const documentedVars = parseEnvExample(envExamplePath)

const missing = reads.filter(
(read) => !documentedVars.has(read.name) && !(read.name in UNDOCUMENTED_ENV_ALLOW_LIST)
)

expect(missing).toHaveLength(1)
expect(missing[0].name).toBe('MCP_SOME_NEW_UNDOCUMENTED_KEY')
})


it('requires every allow-list entry to have an explicit rationale', () => {
const entries = Object.entries(UNDOCUMENTED_ENV_ALLOW_LIST)
expect(entries.length).toBeGreaterThan(0)
Expand Down
10 changes: 4 additions & 6 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ export const MCP_ERROR_CODES = {
ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE',
ERR_NO_RESULTS: 'ERR_NO_RESULTS',
ERR_BAD_REQUEST: 'ERR_BAD_REQUEST',
// Refused before any request was made, for two different reasons:
// ERR_URL_BLOCKED the target resolves somewhere we will never fetch
// (private, loopback, link-local, metadata) — #488
// ERR_HOST_NOT_ALLOWED the host is reachable but is not on the operator's
// paid_call allow-list — #487
ERR_URL_BLOCKED: 'ERR_URL_BLOCKED',
ERR_HOST_NOT_ALLOWED: 'ERR_HOST_NOT_ALLOWED',
ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE'
ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE',
ERR_MISSING_KEY: 'ERR_MISSING_KEY',
ERR_RATE_LIMITED: 'ERR_RATE_LIMITED',
ERR_CONCURRENCY_LIMIT_EXCEEDED: 'ERR_CONCURRENCY_LIMIT_EXCEEDED'
} as const;

export type McpErrorCode = keyof typeof MCP_ERROR_CODES;
Expand Down
Loading