monad-mcp
v0.2.1
Published
MCP server for the Monad blockchain — balances, transfers, swaps, lending, staking, bridging & x402 payments for AI agents, with Privy-backed signing and session keys.
Maintainers
Readme
monad-mcp
MCP server for the Monad blockchain. Your agent's gateway to balances, transfers, swaps, and DeFi on Monad — with signing handled by a Privy embedded wallet, never by the agent.
Built around a simple shape — stored requests + approval URLs + skill plugins — on Monad mainnet 143 / testnet 10143, with Privy as the wallet provider.
🌐 Landing page + live read-only demo: pareen.github.io/monad-mcp — query real Monad balances in your browser, exactly what the agent sees.
🔌 Connect it — no install, no terminal. In Claude Desktop or ChatGPT, add a custom connector and paste this URL:
https://monad-mcp.fly.dev/mcpThat's the whole setup — read tools (balances, prices, portfolios, token checks) work immediately. Step-by-step for each app: docs/connect-claude.md.
Prefer the terminal? One line in Claude Code:
claude mcp add --transport http monad https://monad-mcp.fly.dev/mcp # hosted
claude mcp add monad -- npx -y monad-mcp # or run it locallyThe hosted endpoint runs in split mode: read tools are public (paste the URL,
no login — safe to share) and write tools are gated behind a Privy sign-in,
returning auth_required until you authenticate. The hosted sign-in path is wired
but still being verified end-to-end, so the tested route for transactions is your
own Privy-signed instance — see docs/connect-claude.md, docs/FAQ.md for common security/setup questions, and docs/DEPLOY.md.
🧭 Monad ≠ Ethereum: the server ships agent-facing guidance on Monad's quirks (128 KB contracts, gas_limit billing, speculative latest, eth_getLogs caps, FastLane/MEV) as monad://guide/* MCP resources — see docs/building-on-monad.md.
What's in the box
Core tools (work on any address; auth required only where noted):
| Tool | Auth | What it does |
|------|------|--------------|
| create_user | none | Provision a Privy user + Monad-ready embedded wallet. Self-serve onboarding. |
| whoami | required | User id, wallet address, and active session-key grants. |
| get_address | required | Returns the connected wallet's address. |
| get_balance | optional | Native MON balance. Accepts a 0x address or a .nad name. |
| get_token_balance | optional | ERC-20 balance with decimals + symbol. Accepts a .nad name. |
| get_portfolio | optional | All canonical token balances × USD prices via DexScreener. Accepts a .nad name. |
| get_transaction_history | optional | Recent ERC-20 transfers via RPC log scan + explorer URL. Accepts a .nad name. |
| get_tx_receipt | none | Receipt for a tx hash. |
| simulate_transaction | optional | Dry-run a call (eth_call), returns return data or revert reason. |
| check_token | none | Risk heuristics: canonical-list membership, bytecode, DEX liquidity, pair age. |
| resolve_token | none | Symbol/name/alias → token address (canonical list + DexScreener fallback). |
| get_token_price | none | USD price from the deepest-liquidity DexScreener pair on Monad. |
| list_canonical_tokens | none | Built-in list of well-known Monad tokens. |
| resolve_name | none | Nad Name Service (nad.domains): .nad name → address, and address → primary .nad name. |
| read_contract | none | Call any view/pure function on any Monad contract (ABI provided inline). |
| decode_return_data | none | Decode hex return data against an ABI. |
| bridge_quote | optional | LiFi aggregator quote from any chain into Monad. |
| poll_request | none | Status of a pending approval request. |
| transfer | required | Native MON or ERC-20 transfer. Recipient may be a 0x address or a .nad name. Auto-executes under a session grant, else → approval URL. |
| write_contract | required | Send a tx to any contract (any non-view function). |
| pay_for_service | required | x402 (HTTP 402) — signs an EIP-3009 USDC authorization via Privy and retries with X-PAYMENT. |
| bridge_execute | required | Submit the source-chain tx returned by bridge_quote (Monad sources only). |
| grant_session_key | required | Authorize the agent to spend up to N MON without per-tx approvals. |
| list_session_keys | required | Show active + recent grants. |
| revoke_session_key | required | Cancel a grant immediately. |
Skill plugins (additive, drop in your own under src/plugins/):
- Uniswap (
uniswap_quote,uniswap_swap,approve_erc20) — exact-input single-hop swaps on Uniswap v3 (testnet + mainnet). - Kintsu (
kintsu_stake,kintsu_request_unstake,kintsu_claim_unstake,kintsu_position) — liquid staking MON → sMON via Kintsu's ERC-7535 vault. Two-step unstake with batch processing. - FastLane (
fastlane_stake,fastlane_unstake,fastlane_position) — liquid staking MON → shMON via FastLane's shMONAD ERC-4626 vault. Payable native deposit, synchronous redeem (no epoch wait); shMON keeps earning staking + MEV rewards. - Morpho (
morpho_supply,morpho_withdraw,morpho_borrow,morpho_repay,morpho_position,morpho_market) — lending on Morpho Blue's singleton. Caller passes MarketParams inline; plugin derives the market id. - Kuru (
kuru_best_bid_ask,kuru_market_params,kuru_place_limit,kuru_cancel_orders,kuru_market_swap) — fully-on-chain CLOB. Ships with known mainnet markets (MON/USDC, MON/AUSD, WETH/USDC); accepts raw addresses for new ones.
All write tools across plugins auto-route through a covering session-key grant (see "Session keys" below); otherwise they return an approval URL.
Mainnet-only plugins (Kintsu / FastLane / Morpho / Kuru) are skipped at boot when MONAD_DEFAULT_NETWORK=testnet. Override per-tool with network: "mainnet".
Quickstart
1. Install + configure
git clone https://github.com/you/monad-mcp.git
cd monad-mcp
npm install
cp .env.example .envEdit .env. Minimum config to do anything useful with write tools:
PRIVY_APP_ID=clxxxxxxxxxxx # from https://dashboard.privy.io
PRIVY_APP_SECRET=... # from the same place
PRIVY_VERIFICATION_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" # optional but recommended
# Where this server is reachable from a browser (used in approval URLs).
PUBLIC_BASE_URL=http://localhost:8787
PORT=8787
# Which Monad network to default to when a tool call omits `network`.
MONAD_DEFAULT_NETWORK=testnetFor the Uniswap plugin, supply contract addresses (verify against the canonical Uniswap deployment for Monad before using):
UNISWAP_MAINNET_QUOTER_V2=0x...
UNISWAP_MAINNET_SWAP_ROUTER_02=0x...
UNISWAP_TESTNET_QUOTER_V2=0x...
UNISWAP_TESTNET_SWAP_ROUTER_02=0x...2. Privy app setup
In the Privy dashboard:
- Create an app. Enable embedded wallets under "Wallets".
- Under "Networks", add Monad mainnet (chainId
143) and/or testnet (chainId10143) as an EVM chain. - Copy the App ID + App Secret into
.env. - Copy the JWT verification key (PEM-encoded ES256 public key) into
PRIVY_VERIFICATION_KEYfor fastest token verification. If omitted, the server falls back to JWKS over the network. - (Optional) Configure wallet policies (spend limits, allowlists) to gate what the agent can do.
2a. Bootstrap server-side wallet authorization (one-time)
Privy embedded wallets are user-owned by default — the server cannot sign for them. The MCP server needs a P-256 key quorum registered with Privy, attached as an additional_signer on every wallet it provisions. Bootstrap it:
npm run bootstrap:auth-key
# Generates a P-256 keypair, registers it as a Privy key quorum, prints two env vars.
# Paste PRIVY_AUTHORIZATION_PRIVATE_KEY and PRIVY_KEY_QUORUM_ID into .env.After bootstrap, every create_user call attaches the quorum at wallet creation, and every transfer/swap includes the private key in authorization_context. Important: wallets minted before you set these env vars are stranded — Privy doesn't let you retrofit an additional_signer (updating authorization requires existing authorization). Always bootstrap first.
3. Run
# Stdio mode for Claude Desktop, Cursor, etc.
npm run build
npm start
# HTTP / Streamable HTTP mode for remote MCP clients (Claude Web, ChatGPT).
npm run start:http
# Server listens on http://localhost:8787/mcp4. Wire it into Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json. The --env-file flag loads PRIVY_*, MONAD_*, and PUBLIC_BASE_URL from your .env into the spawned process:
{
"mcpServers": {
"monad": {
"command": "node",
"args": [
"--env-file=/absolute/path/to/monad-mcp/.env",
"/absolute/path/to/monad-mcp/dist/index.js"
]
}
}
}You'll also need the HTTP server running (npm run start:http) for the approval flow — the stdio MCP returns approval URLs that point at the HTTP server. See docs/claude-desktop.md for the full dogfood walkthrough (bootstrap, funding, first transfer).
Public reads, no self-hosting: to use the read tools (balances, portfolio, history) without building anything, point Claude Desktop at the hosted endpoint via the
mcp-remoteshim —"args": ["-y", "mcp-remote", "https://monad-mcp.fly.dev/mcp"]. Writes (transfers/swaps) are gated behind a Privy sign-in; the verified path for them is a self-hosted signer.
5. Try a prompt
What's the balance of 0x000…0000 on Monad testnet?
Send 0.01 MON to 0xabc… on Monad testnet.For the second prompt the agent calls transfer, hands you a link like http://localhost:8787/approve/<uuid>, and you approve in your browser. The agent polls poll_request and reports the tx hash.
Architecture
agent (Claude / ChatGPT / Cursor)
│
│ MCP over stdio OR Streamable HTTP (with OAuth 2.1 bearer)
▼
monad-mcp server
│
├── core tools viem → Monad RPC (mainnet 143 / testnet 10143)
├── skill plugins Uniswap / Kuru / Kintsu / Morpho (additive)
│
├── auth bridge Privy server SDK (verify access token, resolve embedded wallet)
│
└── stored requests pending tx ↔ approval URL ↔ user signature
▲
│
└── /approve/:id → Privy hosted signing → tx hashWrite tools follow the stored request pattern:
- Tool builds an unsigned
{ to, value, data }payload and a human-readable summary. - Server stores it under a UUID and returns
https://your-server/approve/<uuid>to the agent. - User opens the URL — page shows the summary + asset diff + signs via their Privy embedded wallet.
- Server records the tx hash; agent polls
poll_requestand continues.
The agent never touches a private key. Privy's TEE-backed key custody + wallet policies are the security envelope.
Adding your own skill plugin
Plugins live in src/plugins/<id>/ and export a SkillPlugin:
import type { SkillPlugin } from "../types.js";
import { registerTool } from "../../tools/registry.js";
export const myPlugin: SkillPlugin = {
id: "myproto",
name: "MyProto on Monad",
description: "What it does in one sentence.",
networks: ["mainnet", "testnet"],
register(mcp, server) {
registerTool(mcp, server, myReadTool);
registerTool(mcp, server, myWriteTool);
},
};Register it in src/plugins/index.ts. Write tools just need to construct a { to, value, data } payload and call server.store.create(...) — the approval flow is shared.
Testing
npm test # vitest unit tests (no network) — 56 tests
npm run test:integration # integration tests against live testnet RPC + Privy
npm run typecheck
npm run lint56 unit tests cover the chain config, both stores (requests + grants), tool registry (auth gating, schema validation), every core tool, every session-key surface, and the Uniswap plugin's quote sweep.
The integration suite runs transfer end-to-end on Monad testnet through the Privy server SDK: build → sign → broadcast → poll receipt. Gated on PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_AUTHORIZATION_PRIVATE_KEY, PRIVY_KEY_QUORUM_ID, and MONAD_MCP_E2E_USER_ID being set + the wallet being funded; cleanly skips otherwise so it doesn't break CI without secrets.
Security model
- Keys: Privy holds the key in a TEE. The MCP server never sees a private key.
- Authorization to act: every write tool builds a stored request; nothing happens until the user clicks Approve on the approval page.
- Token scope: the OAuth 2.1 bearer in the MCP transport identifies which user; it does not authorize which tx — that's gated by the explicit approval click + Privy wallet policies.
- Stale link defense: stored requests expire after
ttl_seconds(default 5 min); expired requests can't be approved. - CSRF on /reject: rejection is intentionally permissionless — worst case, a stale link can't be re-used. Approval requires a Bearer token tied to the requesting user.
For production, the stored-request and grant stores already support a Postgres backend (set STORE_BACKEND=postgres); still on the hardening list are rate limiting on /approve and /submit, and per-tool spend caps via Privy policies.
Session keys: skipping per-tx approvals
By default every write tool returns an approval URL the user has to click. That's fine for one-shot prompts, fatal for anything agentic ("DCA $100 into MON every Friday", "rebalance my LP weekly"). The fix is grant_session_key:
> Grant the agent up to 0.5 MON for the next 24h to send to 0xabc…
agent calls grant_session_key({
spend_cap_mon: "0.5",
ttl_seconds: 86400,
allowed_targets: ["0xabc..."],
})
→ returns an approval URL. User clicks once.
> Now send 0.01 MON to 0xabc…
agent calls transfer({ to: "0xabc...", amount: "0.01" })
→ no approval URL. Tool returns the tx hash immediately,
grant remaining: 0.49 MON.Grants are scoped by:
spend_cap_mon— total native MON the agent can spend (running total tracked across calls)ttl_seconds— grant expiry window (max 30 days)allowed_targets— optional recipient/contract allowlist (empty = any)allowed_selectors— optional 4-byte function selector allowlist (e.g.0xa9059cbb= ERC-20 transfer only)
Coverage is checked against every write call; if the grant doesn't cover (wrong target, wrong selector, would exceed cap), the tool falls back to the approval-URL flow. Revoke any time with revoke_session_key.
Limits: v1 tracks native-MON spend cap only. ERC-20 token amounts aren't deducted from the cap — instead, restrict ERC-20 access by listing the token contract in allowed_targets. Per-token spend caps are a v2 add.
End-to-end testing on Monad testnet
# 1. Provision a test user + embedded wallet (one-time, costs a Privy seat).
npm run e2e:create-user
# Prints: user_id, wallet_address, faucet URL. Save user_id to .env.
# 2. Fund the printed wallet at https://testnet.monad.xyz/
# 3. Run the round-trip transfer test.
npm run e2e:transfer
# Builds a transfer through runTool, signs+broadcasts via Privy, waits for receipt.
# 4. Sanity-check Privy creds reach the API.
npm run verify:privyThe e2e script reuses MONAD_MCP_E2E_USER_ID from env so you don't burn a new Privy seat on each run.
What's next
- Browser-first signing path (Privy web SDK in the approval page → no server-side
sendTransactionround-trip). - More plugins — Neverland lending (Aave V3 fork on Monad), additional DEXes/perps.
- Block-explorer integration once Monad's explorer API stabilizes (
get_transaction_historywill return full decoded history). - x402 payment support (pay for x402-enabled services).
- Per-token (ERC-20) spend caps on session grants — today's cap is a single native-MON budget per grant.
- Universal contract reader/writer for "talk to any Monad contract" without writing a per-protocol plugin.
License
MIT — see LICENSE.
