Overview
Events Concierge is a production-ready reference implementation showing how AI agents can autonomously pay for MCP (Model Context Protocol) tool calls using smart wallets and the x402 payment protocol.
What makes this different: Real on-chain USDC payments on Base, autonomous agent-to-agent transactions, and production-grade multi-tenant architecture—no mocks, no simulations, no toy examples.
Why This Matters
Traditional approaches to monetizing MCP servers require:
- Manual payment flows that break agent autonomy
- Private key management (security nightmare)
- Custom payment infrastructure
- User intervention for every transaction
This demo shows a better way:
User: "RSVP to event abc-123"
↓
Guest Agent: Detects paid tool, checks price ($0.05)
↓
Guest Agent: Signs USDC payment autonomously
↓
Host Agent: Verifies signature, settles on Base
↓
Guest Agent: "You're registered! TX: 0xabc...def"
Zero user intervention after initial confirmation. The agent handles everything: signature generation, payment submission, transaction verification, error recovery.
Quick Start
# 1. Install dependencies
npm install
# 2. Get API keys
# - Crossmint Console: https://www.crossmint.com/console
# - OpenAI Platform: https://platform.openai.com/api-keys
# 3. Configure environment
cp .dev.vars.example .dev.vars
# Edit .dev.vars:
# CROSSMINT_API_KEY=sk_...
# OPENAI_API_KEY=sk-...
# 4. Run locally
npm run dev
Visit http://localhost:5173 to see the chat interface.
Your First Autonomous Payment
1. Connect the Agents
- Click “Connect to Events” in the chat
- Guest agent establishes MCP connection to Host
2. Create an Event (as Host)
- Go to
http://localhost:5173/?view=my-mcp
- Sign in with email OTP
- Create an event with a price (e.g., $0.05)
3. Get Testnet USDC
- Copy the Guest wallet address from chat
- Visit Circle Faucet
- Select “Base Sepolia” and paste address
- Mint 1 USDC
4. Watch Autonomous Payment
- Say:
rsvp to event <event-id>
- Guest agent detects 402 response
- Confirms payment with user
- Signs and submits USDC transfer
- Retries MCP call with payment proof
- Displays TX hash and confirmation
No MetaMask. No private keys. No manual signing. Just HTTP and smart contracts.
Core Technologies
This demo combines four production technologies:
MCP tools are functions that agents can call. The x402 protocol adds payment requirements:
// Define a paid tool
this.server.paidTool(
"rsvpToEvent", // Tool name
"RSVP to a paid event", // Description
0.05, // Price in USD
{ eventId: z.string() }, // Input schema
{}, // Output schema
async ({ eventId }) => {
// Your business logic
const event = await getEvent(eventId);
await recordRsvp(eventId, guestWallet);
return { success: true, event };
}
);
What happens when called:
1. Guest Agent: "Call rsvpToEvent(eventId='abc-123')"
↓
2. Host Agent: HTTP 402 Payment Required
{
"payment": {
"amount": "50000", // 0.05 USDC (6 decimals)
"currency": "USDC",
"to": "0x123...",
"chainId": 84532
}
}
↓
3. Guest Agent: Signs payment with smart wallet
↓
4. Guest Agent: Retries with header: X-PAYMENT: <signature>
↓
5. Host Agent: Verifies signature → Settles on-chain → Executes tool
↓
6. Guest Agent: Receives result + TX hash
The 402 status code (reserved since HTTP/1.1 in 1997) finally has a use case. Payment requirements are just HTTP headers.
2. x402: HTTP-Native Payments
x402 makes the 402 Payment Required status code work for real transactions:
// Auto-handle 402 responses
const server = new McpServer({ ... })
.withX402Client({
wallet: crossmintWallet,
onPaymentRequired: async (requirement, retryFn) => {
// Confirm with user
const approved = await askUser(`Pay ${requirement.amount} USD?`);
if (!approved) return;
// Sign payment (EIP-712 typed data)
const signature = await wallet.signPayment(requirement);
// Retry request with proof
return retryFn(signature);
}
});
Payment Flow:
Payments are just HTTP retries with cryptographic proofs. No WebSockets, no polling, no custom protocols.
3. Cloudflare Durable Objects: Stateful Agents
Normal serverless functions are stateless—great for APIs, terrible for agents that need to maintain conversation state, MCP connections, and payment history.
Durable Objects are:
- Single-threaded - No race conditions, guaranteed serial execution
- Stateful - In-memory state persists across requests
- Globally unique - Only ONE instance per ID worldwide
- Auto-scaling - Created on-demand, hibernate when idle
export class Host extends DurableObject {
private wallet: CrossmintWallet;
private server: McpServer;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Initialize once, persists across requests
this.wallet = await createHostWallet(env);
this.server = new McpServer({ ... });
}
async fetch(request: Request) {
// All requests to /mcp/users/abc-123 hit THIS instance
return this.server.handleRequest(request);
}
}
Per-user isolation:
User A → /mcp/users/hash-a → Host DO (name: "hash-a")
├─ wallet: 0xAAA...
├─ events: [...]
└─ revenue: $12.50
User B → /mcp/users/hash-b → Host DO (name: "hash-b")
├─ wallet: 0xBBB...
├─ events: [...]
└─ revenue: $8.00
Why not regular Workers?
| Need | Regular Worker | Durable Object |
|---|
| MCP connection state | ❌ Lost between requests | ✅ Persists in memory |
| WebSocket support | ❌ No state | ✅ Built-in |
| Coordination | ❌ Race conditions | ✅ Single-threaded |
| Per-user isolation | ❌ Shared instance | ✅ Unique per ID |
Durable Objects are “mini-servers per user” that never have concurrency bugs. Perfect for stateful agent protocols like MCP.
4. Crossmint Smart Wallets: No Private Keys
Traditional wallets require managing private keys—terrible UX, security nightmare, non-starter for autonomous agents.
Crossmint smart wallets:
- Controlled via API (server-side) or email OTP (client-side)
- ERC-4337 compliant smart contract accounts
- Work before on-chain deployment (ERC-6492 signatures)
- Auto-deploy on first transaction
- Validate signatures via EIP-1271
// Create wallet from API key
const wallet = CrossmintWalletService.from({
apiKey: env.CROSSMINT_API_KEY,
chain: "base-sepolia"
});
// Sign payment (works even if wallet not deployed yet!)
const signature = await wallet.signTypedData({
domain: { chainId: 84532, ... },
types: { Payment: [...] },
primaryType: "Payment",
message: { amount: "50000", to: "0x123...", ... }
});
Smart Wallet Lifecycle:
ERC-6492 vs EIP-1271:
| Stage | Standard | How It Works |
|---|
| Pre-deployed | ERC-6492 | Signature includes deployment bytecode. Verifiers simulate deployment, check signature against simulated contract. |
| Deployed | EIP-1271 | Standard smart contract signature validation. Contract’s isValidSignature() verifies. |
Smart wallets can sign transactions before they exist on-chain. No deployment costs until first real transaction.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Browser (React UI) │
│ • Chat interface for user commands │
│ • WebSocket connection to Guest Agent │
│ • Payment confirmation modal │
└─────────────────────┬───────────────────────────────────────┘
│ WebSocket
↓
┌─────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (Gateway) │
│ • Route: /agent → Guest Agent DO │
│ • Route: /mcp/users/{id} → Host Agent DO │
│ • Route: /api/* → KV operations │
└─────────────────────┬──────────────┬────────────────────────┘
│ │
┌────────────┘ └────────────┐
↓ ↓
┌──────────────────────┐ ┌──────────────────────┐
│ Guest Agent DO │◄─────MCP────►│ Host Agent DO │
│ (name: "default") │ Protocol │ (name: userId hash) │
│ │ │ │
│ • WebSocket server │ │ • MCP server │
│ • Crossmint wallet │ │ • Paid tools │
│ • Payment signer │ │ • Event storage │
│ • Shared instance │ │ • Per-user instance │
└──────────────────────┘ └─────────┬────────────┘
│ │
│ ↓
│ ┌──────────────────────┐
│ │ Cloudflare KV │
│ │ • users:{email} │
│ │ • {hash}:events:* │
│ │ • {hash}:revenue │
│ └──────────────────────┘
│
└──────────────┬─────────────────────────────────────┐
↓ ↓
┌───────────────────┐ ┌──────────────────────┐
│ Crossmint API │ │ Base Sepolia │
│ • Wallet mgmt │ │ • USDC settlement │
│ • Signature API │ │ • TX verification │
└───────────────────┘ └──────────────────────┘
Code Walkthrough
File Structure
events-concierge/
├── src/
│ ├── agents/
│ │ ├── host.ts # MCP server with paid tools (300 LOC)
│ │ └── guest.ts # MCP client with auto-payment (200 LOC)
│ │
│ ├── shared/
│ │ └── eventService.ts # KV operations for events (150 LOC)
│ │
│ ├── utils/
│ │ ├── cors.ts # CORS headers for MCP
│ │ └── hashing.ts # User ID → URL-safe hash
│ │
│ ├── x402Adapter.ts # Crossmint → viem wallet adapter (100 LOC)
│ ├── server.ts # Cloudflare Worker entry point (200 LOC)
│ ├── client.tsx # React UI (optional for demo)
│ └── constants.ts # Chain config, addresses
│
├── wrangler.toml # Durable Object bindings
└── package.json
Location: src/agents/host.ts
export class Host extends DurableObject {
private wallet: CrossmintWallet;
private server: McpServer;
private userId: string;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.userId = ctx.id.toString();
// Create wallet from API key (server-side, no user interaction)
this.wallet = await CrossmintWalletService.from({
apiKey: env.CROSSMINT_API_KEY,
chain: "base-sepolia"
});
// Initialize MCP server with x402 support
this.server = new McpServer({
name: `Event Host for ${this.userId}`,
version: "1.0.0"
})
.withX402({
wallet: this.wallet,
facilitator: X402_FACILITATOR_URL,
usdcAddress: USDC_ADDRESS,
chainId: BASE_SEPOLIA_CHAIN_ID,
});
this.registerTools();
}
private registerTools() {
// FREE tool: List events
this.server.tool(
"listEvents",
"List all events created by this host",
{},
{},
async () => {
const events = await listEvents(this.env.KV, this.userId);
return { events };
}
);
// PAID tool: RSVP to event ($0.05)
this.server.paidTool(
"rsvpToEvent",
"RSVP to an event (requires payment)",
0.05, // Price in USD
{ eventId: z.string() },
{},
async ({ eventId }, paymentTx: string) => {
// Only executed if payment verified!
const event = await getEvent(this.env.KV, this.userId, eventId);
if (!event) throw new Error("Event not found");
// Record RSVP
await recordRsvp(this.env.KV, this.userId, eventId, {
guestWallet: paymentTx.from,
paidAmount: "50000",
txHash: paymentTx.hash,
timestamp: Date.now()
});
return {
success: true,
event,
transactionHash: paymentTx.hash,
message: `RSVP confirmed! Paid ${event.price} USDC.`
};
}
);
}
}
Key insights:
paidTool() vs tool() - only difference is the price parameter
- Payment verification happens BEFORE your handler runs
- Transaction hash passed as second parameter to handler
- KV keys scoped by userId for isolation
Guest Agent: Auto-Paying Client
Location: src/agents/guest.ts
export class Guest extends DurableObject {
private wallet: CrossmintWallet;
private mcpClient: McpClient | null = null;
async connectToHost(hostMcpUrl: string) {
this.mcpClient = new McpClient({
url: hostMcpUrl,
transport: "http"
})
.withX402Client({
wallet: this.wallet,
onPaymentRequired: async (requirement, retryFn) => {
// 1. Notify user via WebSocket
this.websocket?.send(JSON.stringify({
type: "payment_required",
amount: requirement.amount,
currency: requirement.currency,
tool: requirement.tool,
}));
// 2. Wait for user confirmation
const approved = await this.waitForConfirmation();
if (!approved) throw new Error("Payment declined");
// 3. Sign payment (Crossmint handles EIP-712)
const signature = await this.wallet.signPayment(requirement);
// 4. Retry MCP call with signature
const result = await retryFn(signature);
// 5. Notify user of success + TX hash
this.websocket?.send(JSON.stringify({
type: "payment_confirmed",
txHash: result.transactionHash,
explorerUrl: `https://sepolia.basescan.org/tx/${result.transactionHash}`
}));
return result;
}
});
await this.mcpClient.connect();
}
async callTool(toolName: string, args: any) {
// This will automatically handle 402 responses!
return this.mcpClient.callTool(toolName, args);
}
}
Key insights:
.withX402Client() adds automatic 402 handling
onPaymentRequired callback gets retry function
- Confirmation flow is customizable (auto-approve, user prompt, etc.)
- Signature and TX hash sent to browser for transparency
x402 Adapter: Crossmint → viem Bridge
Location: src/x402Adapter.ts
The x402 SDK expects a viem account. Crossmint provides a wallet API. This adapter bridges them:
import { type Account } from "viem";
import { CrossmintWallet } from "@crossmint/client-sdk";
export function createViemAccountFromCrossmint(
wallet: CrossmintWallet
): Account {
return {
address: wallet.address as `0x${string}`,
type: "local",
async signTransaction(tx: TransactionRequest) {
const signature = await wallet.signTransaction(tx);
return signature as `0x${string}`;
},
async signTypedData(typedData: TypedDataDefinition) {
const signature = await wallet.signTypedData(typedData);
return signature as `0x${string}`;
},
async signMessage({ message }: { message: string | Uint8Array }) {
const signature = await wallet.signMessage(message);
return signature as `0x${string}`;
}
};
}
This adapter pattern works for ANY wallet provider (Privy, Magic, Dynamic, etc.) by implementing viem’s Account interface.
Testing the Payment Flow
1. Check Wallet Balances
# Guest wallet (needs USDC to pay)
cast balance <GUEST_WALLET_ADDRESS> --rpc-url https://sepolia.base.org
# Host wallet (receives USDC)
cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \
"balanceOf(address)(uint256)" \
<HOST_WALLET_ADDRESS> \
--rpc-url https://sepolia.base.org
2. Test RSVP Payment Flow
In browser console (http://localhost:5173):
ws = new WebSocket("ws://localhost:5173/agent");
ws.onmessage = (e) => console.log("Received:", JSON.parse(e.data));
// Connect to host
ws.send(JSON.stringify({
type: "connect",
hostUrl: "http://localhost:5173/mcp/users/<userId>"
}));
// List events (free tool)
ws.send(JSON.stringify({
type: "call_tool",
tool: "listEvents",
args: {}
}));
// RSVP to event (paid tool, triggers 402 flow)
ws.send(JSON.stringify({
type: "call_tool",
tool: "rsvpToEvent",
args: { eventId: "<event-id>" }
}));
3. Verify Transaction On-Chain
# Get TX details
cast tx <TX_HASH> --rpc-url https://sepolia.base.org
# Check USDC transfer event
cast receipt <TX_HASH> --rpc-url https://sepolia.base.org | grep Transfer
Expected output:
Transfer(from: 0x<guest>, to: 0x<host>, value: 50000)
Deployment to Production
1. Create Cloudflare Account
2. Create KV Namespace
# Production KV
npx wrangler kv:namespace create "KV"
# Preview KV (for staging)
npx wrangler kv:namespace create "KV" --preview
Copy the namespace IDs into wrangler.toml:
[[kv_namespaces]]
binding = "KV"
id = "abc123..." # Production ID
preview_id = "def456..." # Preview ID
3. Set Secrets
npx wrangler secret put OPENAI_API_KEY
# Paste your key when prompted
npx wrangler secret put CROSSMINT_API_KEY
# Paste your key when prompted
4. Deploy
npm run deploy
# or
npx wrangler deploy
Your app is now live at https://events-concierge.<your-subdomain>.workers.dev
5. Switch to Mainnet
Update src/constants.ts:
// Before (testnet)
export const CHAIN_ID = 84532;
export const CHAIN_NAME = "base-sepolia";
export const USDC_ADDRESS = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
// After (mainnet)
export const CHAIN_ID = 8453;
export const CHAIN_NAME = "base";
export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
Redeploy with npm run deploy.
Production Checklist
Resources
Protocols & Standards