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/mcp
That'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
claude mcp add monad -- npx -y monad-mcp
The 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
git clone https://github.com/you/monad-mcp.git
cd monad-mcp
npm install
cp .env.example .env
Edit .env. Minimum config to do anything useful with write tools:
PRIVY_APP_ID=clxxxxxxxxxxx
PRIVY_APP_SECRET=...
PRIVY_VERIFICATION_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
PUBLIC_BASE_URL=http://localhost:8787
PORT=8787
MONAD_DEFAULT_NETWORK=testnet
For 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 (chainId 10143) 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_KEY for 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
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
npm run build
npm start
npm run start:http
4. 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-remote shim β "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 hash
Write 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_request and 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
npm run test:integration
npm run typecheck
npm run lint
56 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
npm run e2e:create-user
npm run e2e:transfer
npm run verify:privy
The 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
sendTransaction round-trip).
- More plugins β Neverland lending (Aave V3 fork on Monad), additional DEXes/perps.
- Block-explorer integration once Monad's explorer API stabilizes (
get_transaction_history will 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.