@moly-mcp/lido 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @moly-mcp/lido
2
+
3
+ MCP server for Lido — stake, unstake, wrap, bridge, and govern directly from any MCP-compatible AI client (Claude Desktop, Cursor, etc.).
4
+
5
+ Runs on Bun. Supports Holesky testnet (simulation mode) and Ethereum mainnet (live mode).
6
+
7
+ ---
8
+
9
+ ## Tools
10
+
11
+ | Tool | Description |
12
+ |---|---|
13
+ | `get_balance` | ETH, stETH, wstETH balances + staking APR |
14
+ | `get_rewards` | Reward history over N days |
15
+ | `get_conversion_rate` | Current stETH / wstETH exchange rate |
16
+ | `stake_eth` | Stake ETH to receive stETH |
17
+ | `request_withdrawal` | Queue stETH withdrawal back to ETH |
18
+ | `claim_withdrawals` | Claim finalized withdrawal requests |
19
+ | `get_withdrawal_requests` | List pending withdrawal request IDs |
20
+ | `get_withdrawal_status` | Check finalization status of requests |
21
+ | `wrap_steth` | Wrap stETH into wstETH |
22
+ | `unwrap_wsteth` | Unwrap wstETH back to stETH |
23
+ | `get_proposals` | List recent Lido DAO governance proposals |
24
+ | `get_proposal` | Get details on a specific proposal |
25
+ | `cast_vote` | Vote YEA / NAY on a DAO proposal |
26
+ | `get_l2_balance` | ETH + wstETH balances on Base or Arbitrum |
27
+ | `get_bridge_quote` | Quote for bridging from L2 to L1 via LI.FI |
28
+ | `bridge_to_ethereum` | Execute L2 to L1 bridge via LI.FI |
29
+ | `get_bridge_status` | Track an in-progress bridge transaction |
30
+
31
+ ---
32
+
33
+ ## Modes
34
+
35
+ | Mode | Network | Default for write ops |
36
+ |---|---|---|
37
+ | `simulation` | Holesky testnet | `dry_run: true` (no real txs) |
38
+ | `live` | Ethereum mainnet | `dry_run: false` (real txs) |
39
+
40
+ Set `LIDO_MODE=simulation` (default) to experiment safely. Set `LIDO_MODE=live` for mainnet.
41
+
42
+ ---
43
+
44
+ ## Setup
45
+
46
+ **1. Copy env file**
47
+
48
+ ```bash
49
+ cp .env.example .env
50
+ ```
51
+
52
+ **2. Fill in `.env`**
53
+
54
+ ```env
55
+ LIDO_MODE=simulation # or "live" for mainnet
56
+ PRIVATE_KEY=0x... # wallet private key
57
+ HOLESKY_RPC_URL=... # optional, has public fallback
58
+ MAINNET_RPC_URL=... # optional, has public fallback
59
+ REFERRAL_ADDRESS=0x... # optional
60
+ ```
61
+
62
+ **3. Install dependencies**
63
+
64
+ ```bash
65
+ bun install
66
+ ```
67
+
68
+ **4. Run**
69
+
70
+ ```bash
71
+ bun run start
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Claude Desktop Config
77
+
78
+ Add to `claude_desktop_config.json`:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "lido": {
84
+ "command": "bunx",
85
+ "args": ["@moly-mcp/lido"],
86
+ "env": {
87
+ "LIDO_MODE": "simulation",
88
+ "PRIVATE_KEY": "0x..."
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Source Structure
98
+
99
+ ```
100
+ src/
101
+ index.ts entry point
102
+ server.ts MCP server setup
103
+ config.ts env config + chain constants
104
+ sdk.ts Lido SDK singleton
105
+ wallet.ts viem wallet + L2 client helpers
106
+ tools/
107
+ index.ts tool registration
108
+ balance.ts get_balance, get_rewards
109
+ stake.ts stake_eth
110
+ unstake.ts request_withdrawal, claim_withdrawals, get_withdrawal_*
111
+ wrap.ts wrap_steth, unwrap_wsteth, get_conversion_rate
112
+ governance.ts get_proposals, get_proposal, cast_vote
113
+ bridge.ts get_l2_balance, get_bridge_*, bridge_to_ethereum
114
+ ```
115
+
116
+ ---
117
+
118
+ ## License
119
+
120
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@moly-mcp/lido",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MCP server for Lido — stake, unstake, wrap, bridge, and govern from any MCP-compatible AI client",
6
+ "bin": {
7
+ "lido-mcp": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run src/index.ts",
15
+ "start": "bun run src/index.ts",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "dependencies": {
19
+ "@lidofinance/lido-ethereum-sdk": "^3.4.0",
20
+ "@modelcontextprotocol/sdk": "^1.10.1",
21
+ "viem": "^2.21.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "latest",
25
+ "typescript": "^5"
26
+ }
27
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { holesky, mainnet, base, arbitrum } from 'viem/chains';
2
+ import type { Chain } from 'viem';
3
+
4
+ export type Mode = 'simulation' | 'live';
5
+ export type L2Chain = 'base' | 'arbitrum';
6
+
7
+ const mode = (process.env.LIDO_MODE ?? 'simulation') as Mode;
8
+
9
+ if (mode !== 'simulation' && mode !== 'live') {
10
+ throw new Error(`LIDO_MODE must be "simulation" or "live", got: ${mode}`);
11
+ }
12
+
13
+ export const config = {
14
+ mode,
15
+ isSimulation: mode === 'simulation',
16
+ chain: mode === 'simulation' ? holesky : mainnet,
17
+ chainId: mode === 'simulation' ? 17000 : 1,
18
+ rpcUrl:
19
+ mode === 'simulation'
20
+ ? (process.env.HOLESKY_RPC_URL ?? 'https://ethereum-holesky-rpc.publicnode.com')
21
+ : (process.env.MAINNET_RPC_URL ?? 'https://eth.llamarpc.com'),
22
+ referralAddress: (process.env.REFERRAL_ADDRESS ?? '0x0000000000000000000000000000000000000000') as `0x${string}`,
23
+ };
24
+
25
+ export interface L2ChainConfig {
26
+ chainId: number;
27
+ name: string;
28
+ defaultRpc: string;
29
+ viemChain: Chain;
30
+ wstETH: `0x${string}`;
31
+ }
32
+
33
+ export const L2_CHAINS: Record<L2Chain, L2ChainConfig> = {
34
+ base: {
35
+ chainId: 8453,
36
+ name: 'Base',
37
+ defaultRpc: 'https://mainnet.base.org',
38
+ viemChain: base,
39
+ wstETH: '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452',
40
+ },
41
+ arbitrum: {
42
+ chainId: 42161,
43
+ name: 'Arbitrum One',
44
+ defaultRpc: 'https://arb1.arbitrum.io/rpc',
45
+ viemChain: arbitrum,
46
+ wstETH: '0x5979D7b546E38E9Ab8049dCFAc0B5D35A8De3f6e',
47
+ },
48
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createServer } from './server.js';
4
+
5
+ const { server, modeNote } = createServer();
6
+ const transport = new StdioServerTransport();
7
+ await server.connect(transport);
8
+ console.error(`Lido MCP server started — ${modeNote}`);
package/src/sdk.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { LidoSDK } from '@lidofinance/lido-ethereum-sdk';
2
+ import { createPublicClient, http } from 'viem';
3
+ import { config } from './config.js';
4
+
5
+ const publicClient = createPublicClient({
6
+ chain: config.chain,
7
+ transport: http(config.rpcUrl),
8
+ });
9
+
10
+ let _sdk: LidoSDK | null = null;
11
+
12
+ export function getSDK(): LidoSDK {
13
+ if (_sdk) return _sdk;
14
+
15
+ _sdk = new LidoSDK({
16
+ chainId: config.chainId as 1 | 17000,
17
+ rpcProvider: publicClient,
18
+ });
19
+
20
+ return _sdk;
21
+ }
22
+
23
+ export { publicClient };
package/src/server.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { config } from './config.js';
3
+ import { registerTools } from './tools/index.js';
4
+
5
+ export function createServer() {
6
+ const modeNote = config.isSimulation
7
+ ? 'SIMULATION MODE (Holesky) — write operations are dry_run by default'
8
+ : 'LIVE MODE (Mainnet) — real transactions will be broadcast';
9
+
10
+ const server = new McpServer({
11
+ name: 'lido-mcp',
12
+ version: '0.1.0',
13
+ });
14
+
15
+ registerTools(server, modeNote);
16
+
17
+ return { server, modeNote };
18
+ }
@@ -0,0 +1,50 @@
1
+ import { formatEther } from 'viem';
2
+ import { getSDK } from '../sdk.js';
3
+ import { getAddress } from '../wallet.js';
4
+ import { config } from '../config.js';
5
+
6
+ export async function getBalance(address?: string) {
7
+ const sdk = getSDK();
8
+ const addr = (address ?? getAddress()) as `0x${string}`;
9
+
10
+ const [eth, steth, wsteth] = await Promise.all([
11
+ sdk.core.balanceETH(addr),
12
+ sdk.steth.balance(addr),
13
+ sdk.wsteth.balance(addr),
14
+ ]);
15
+
16
+ return {
17
+ address: addr,
18
+ mode: config.mode,
19
+ network: config.chain.name,
20
+ balances: {
21
+ eth: formatEther(eth),
22
+ stETH: formatEther(steth),
23
+ wstETH: formatEther(wsteth),
24
+ },
25
+ // APR fetched from protocol stats
26
+ };
27
+ }
28
+
29
+ export async function getRewards(address?: string, days = 7) {
30
+ const sdk = getSDK();
31
+ const addr = (address ?? getAddress()) as `0x${string}`;
32
+
33
+ const rewards = await sdk.rewards.getRewardsFromChain({
34
+ address: addr,
35
+ stepBlock: 1000,
36
+ back: { days },
37
+ });
38
+
39
+ return {
40
+ address: addr,
41
+ period: `${days} days`,
42
+ totalRewards: formatEther(rewards.totalRewards ?? 0n),
43
+ totalStaked: formatEther(rewards.totalStaked ?? 0n),
44
+ events: rewards.events?.slice(0, 10).map((e: any) => ({
45
+ type: e.type,
46
+ change: formatEther(e.change ?? 0n),
47
+ balance: formatEther(e.balance ?? 0n),
48
+ })),
49
+ };
50
+ }
@@ -0,0 +1,186 @@
1
+ import { formatEther, parseEther, parseAbi, type Hex } from 'viem';
2
+ import { config, L2_CHAINS } from '../config.js';
3
+ import type { L2Chain } from '../config.js';
4
+ import { getAddress, getL2Wallet, getL2PublicClient } from '../wallet.js';
5
+
6
+ const LIFI_BASE = 'https://li.quest/v1';
7
+
8
+ const ERC20_ABI = parseAbi([
9
+ 'function balanceOf(address) view returns (uint256)',
10
+ 'function allowance(address owner, address spender) view returns (uint256)',
11
+ 'function approve(address spender, uint256 amount) returns (bool)',
12
+ ]);
13
+
14
+ const NATIVE_TOKEN = '0x0000000000000000000000000000000000000000' as const;
15
+ const L1_WSTETH = '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' as const;
16
+
17
+ type BridgeToken = 'ETH' | 'wstETH';
18
+
19
+ function tokenAddress(chain: L2Chain, token: BridgeToken): `0x${string}` {
20
+ if (token === 'ETH') return NATIVE_TOKEN;
21
+ return L2_CHAINS[chain].wstETH;
22
+ }
23
+
24
+ function toTokenAddress(toToken?: string): `0x${string}` {
25
+ if (!toToken || toToken === 'ETH') return NATIVE_TOKEN;
26
+ return L1_WSTETH;
27
+ }
28
+
29
+ function ensureMainnet() {
30
+ if (config.isSimulation) {
31
+ throw new Error('Bridge tools only work in live mode (mainnet). LI.FI does not support testnets.');
32
+ }
33
+ }
34
+
35
+ export async function getL2Balance(sourceChain: L2Chain, address?: string) {
36
+ ensureMainnet();
37
+ const addr = (address ?? getAddress()) as `0x${string}`;
38
+ const client = getL2PublicClient(sourceChain);
39
+ const cfg = L2_CHAINS[sourceChain];
40
+
41
+ const [eth, wsteth] = await Promise.all([
42
+ client.getBalance({ address: addr }),
43
+ client.readContract({ address: cfg.wstETH, abi: ERC20_ABI, functionName: 'balanceOf', args: [addr] }),
44
+ ]);
45
+
46
+ return {
47
+ address: addr,
48
+ chain: cfg.name,
49
+ chainId: cfg.chainId,
50
+ balances: { ETH: formatEther(eth), wstETH: formatEther(wsteth) },
51
+ };
52
+ }
53
+
54
+ export async function getBridgeQuote(
55
+ sourceChain: L2Chain,
56
+ token: BridgeToken,
57
+ amount: string,
58
+ toToken?: string,
59
+ ) {
60
+ ensureMainnet();
61
+ const addr = getAddress();
62
+ const cfg = L2_CHAINS[sourceChain];
63
+ const fromToken = tokenAddress(sourceChain, token);
64
+ const toAddr = toTokenAddress(toToken);
65
+ const fromAmount = parseEther(amount).toString();
66
+
67
+ const url = `${LIFI_BASE}/quote?fromChain=${cfg.chainId}&toChain=1&fromToken=${fromToken}&toToken=${toAddr}&fromAmount=${fromAmount}&fromAddress=${addr}`;
68
+ const res = await fetch(url);
69
+ if (!res.ok) {
70
+ const body = await res.text();
71
+ throw new Error(`LI.FI quote failed (${res.status}): ${body}`);
72
+ }
73
+ const data = await res.json();
74
+
75
+ return {
76
+ sourceChain: cfg.name,
77
+ token,
78
+ amount,
79
+ toToken: toToken ?? 'ETH',
80
+ toAmount: formatEther(BigInt(data.estimate?.toAmount ?? '0')),
81
+ estimatedDuration: `${Math.ceil((data.estimate?.executionDuration ?? 0) / 60)} min`,
82
+ gasCosts: data.estimate?.gasCosts ?? [],
83
+ feeCosts: data.estimate?.feeCosts ?? [],
84
+ tool: data.tool,
85
+ };
86
+ }
87
+
88
+ export async function bridgeToEthereum(
89
+ sourceChain: L2Chain,
90
+ token: BridgeToken,
91
+ amount: string,
92
+ toToken?: string,
93
+ dryRun?: boolean,
94
+ ) {
95
+ ensureMainnet();
96
+ const addr = getAddress();
97
+ const cfg = L2_CHAINS[sourceChain];
98
+ const fromToken = tokenAddress(sourceChain, token);
99
+ const toAddr = toTokenAddress(toToken);
100
+ const fromAmount = parseEther(amount).toString();
101
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
102
+
103
+ const url = `${LIFI_BASE}/quote?fromChain=${cfg.chainId}&toChain=1&fromToken=${fromToken}&toToken=${toAddr}&fromAmount=${fromAmount}&fromAddress=${addr}`;
104
+ const res = await fetch(url);
105
+ if (!res.ok) {
106
+ const body = await res.text();
107
+ throw new Error(`LI.FI quote failed (${res.status}): ${body}`);
108
+ }
109
+ const data = await res.json();
110
+ const txReq = data.transactionRequest;
111
+
112
+ const quote = {
113
+ sourceChain: cfg.name,
114
+ token,
115
+ amount,
116
+ toToken: toToken ?? 'ETH',
117
+ toAmount: formatEther(BigInt(data.estimate?.toAmount ?? '0')),
118
+ estimatedDuration: `${Math.ceil((data.estimate?.executionDuration ?? 0) / 60)} min`,
119
+ tool: data.tool,
120
+ };
121
+
122
+ if (shouldDryRun) {
123
+ return { simulated: true, mode: config.mode, ...quote };
124
+ }
125
+
126
+ const wallet = getL2Wallet(sourceChain);
127
+ const client = getL2PublicClient(sourceChain);
128
+
129
+ // ERC-20 approval for wstETH
130
+ if (token === 'wstETH' && txReq.to) {
131
+ const allowance = await client.readContract({
132
+ address: cfg.wstETH,
133
+ abi: ERC20_ABI,
134
+ functionName: 'allowance',
135
+ args: [addr as `0x${string}`, txReq.to as `0x${string}`],
136
+ });
137
+ if (allowance < BigInt(fromAmount)) {
138
+ const approveTx = await wallet.writeContract({
139
+ address: cfg.wstETH,
140
+ abi: ERC20_ABI,
141
+ functionName: 'approve',
142
+ args: [txReq.to as `0x${string}`, BigInt(fromAmount)],
143
+ chain: cfg.viemChain,
144
+ } as any);
145
+ await client.waitForTransactionReceipt({ hash: approveTx });
146
+ }
147
+ }
148
+
149
+ const hash = await wallet.sendTransaction({
150
+ to: txReq.to as `0x${string}`,
151
+ data: txReq.data as Hex,
152
+ value: txReq.value ? BigInt(txReq.value) : 0n,
153
+ gas: txReq.gasLimit ? BigInt(txReq.gasLimit) : undefined,
154
+ chain: cfg.viemChain,
155
+ } as any);
156
+
157
+ return {
158
+ simulated: false,
159
+ mode: config.mode,
160
+ ...quote,
161
+ txHash: hash,
162
+ note: 'Bridge submitted. Use get_bridge_status to track progress (may take 1-20 min).',
163
+ };
164
+ }
165
+
166
+ export async function getBridgeStatus(txHash: string, sourceChain: L2Chain) {
167
+ ensureMainnet();
168
+ const cfg = L2_CHAINS[sourceChain];
169
+
170
+ const url = `${LIFI_BASE}/status?txHash=${txHash}&fromChain=${cfg.chainId}&toChain=1`;
171
+ const res = await fetch(url);
172
+ if (!res.ok) {
173
+ const body = await res.text();
174
+ throw new Error(`LI.FI status failed (${res.status}): ${body}`);
175
+ }
176
+ const data = await res.json();
177
+
178
+ return {
179
+ txHash,
180
+ sourceChain: cfg.name,
181
+ status: data.status,
182
+ substatus: data.substatus ?? null,
183
+ sending: data.sending ? { txHash: data.sending.txHash, amount: data.sending.amount } : null,
184
+ receiving: data.receiving ? { txHash: data.receiving.txHash, amount: data.receiving.amount } : null,
185
+ };
186
+ }
@@ -0,0 +1,131 @@
1
+ import { createPublicClient, http, parseAbi, formatEther } from 'viem';
2
+ import { getAddress } from '../wallet.js';
3
+ import { getWallet } from '../wallet.js';
4
+ import { config } from '../config.js';
5
+
6
+ // Aragon Voting contract addresses
7
+ const VOTING_ADDRESSES: Record<number, `0x${string}`> = {
8
+ 1: '0x2e59A20f205bB85a89C53f1936454680651E618e', // mainnet Lido DAO Aragon Voting
9
+ 17000: '0xDA7d2573Df555002503F29aA4003e398d28cc00f', // holesky
10
+ };
11
+
12
+ const VOTING_ABI = parseAbi([
13
+ 'function vote(uint256 _voteId, bool _supports, bool _executesIfDecided) external',
14
+ 'function getVote(uint256 _voteId) external view returns (bool open, bool executed, uint64 startDate, uint64 snapshotBlock, uint64 supportRequired, uint64 minAcceptQuorum, uint256 yea, uint256 nay, uint256 votingPower, bytes script)',
15
+ 'function votesLength() external view returns (uint256)',
16
+ ]);
17
+
18
+ export async function getProposals(count = 5) {
19
+ const client = createPublicClient({
20
+ chain: config.chain,
21
+ transport: http(config.rpcUrl),
22
+ });
23
+
24
+ const votingAddress = VOTING_ADDRESSES[config.chainId];
25
+ if (!votingAddress) throw new Error(`No voting contract for chain ${config.chainId}`);
26
+
27
+ const totalVotes = await client.readContract({
28
+ address: votingAddress,
29
+ abi: VOTING_ABI,
30
+ functionName: 'votesLength',
31
+ });
32
+
33
+ const latest = Number(totalVotes);
34
+ const from = Math.max(0, latest - count);
35
+ const ids = Array.from({ length: latest - from }, (_, i) => from + i);
36
+
37
+ const votes = await Promise.all(
38
+ ids.map(async (id) => {
39
+ const v = await client.readContract({
40
+ address: votingAddress,
41
+ abi: VOTING_ABI,
42
+ functionName: 'getVote',
43
+ args: [BigInt(id)],
44
+ });
45
+ return {
46
+ id,
47
+ open: v[0],
48
+ executed: v[1],
49
+ startDate: new Date(Number(v[2]) * 1000).toISOString(),
50
+ yea: formatEther(v[6]),
51
+ nay: formatEther(v[7]),
52
+ votingPower: formatEther(v[8]),
53
+ };
54
+ })
55
+ );
56
+
57
+ return {
58
+ mode: config.mode,
59
+ network: config.chain.name,
60
+ votingContract: votingAddress,
61
+ totalProposals: latest,
62
+ proposals: votes.reverse(),
63
+ };
64
+ }
65
+
66
+ export async function getProposal(proposalId: number) {
67
+ const client = createPublicClient({
68
+ chain: config.chain,
69
+ transport: http(config.rpcUrl),
70
+ });
71
+
72
+ const votingAddress = VOTING_ADDRESSES[config.chainId];
73
+ const v = await client.readContract({
74
+ address: votingAddress,
75
+ abi: VOTING_ABI,
76
+ functionName: 'getVote',
77
+ args: [BigInt(proposalId)],
78
+ });
79
+
80
+ return {
81
+ mode: config.mode,
82
+ id: proposalId,
83
+ open: v[0],
84
+ executed: v[1],
85
+ startDate: new Date(Number(v[2]) * 1000).toISOString(),
86
+ snapshotBlock: v[3].toString(),
87
+ supportRequired: `${(Number(v[4]) / 1e16).toFixed(1)}%`,
88
+ minAcceptQuorum: `${(Number(v[5]) / 1e16).toFixed(1)}%`,
89
+ yea: formatEther(v[6]),
90
+ nay: formatEther(v[7]),
91
+ votingPower: formatEther(v[8]),
92
+ };
93
+ }
94
+
95
+ export async function castVote(proposalId: number, support: boolean, dryRun?: boolean) {
96
+ const votingAddress = VOTING_ADDRESSES[config.chainId];
97
+ if (!votingAddress) throw new Error(`No voting contract for chain ${config.chainId}`);
98
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
99
+ const account = getAddress();
100
+
101
+ if (shouldDryRun) {
102
+ const proposal = await getProposal(proposalId);
103
+ return {
104
+ simulated: true,
105
+ mode: config.mode,
106
+ action: 'cast_vote',
107
+ proposalId,
108
+ vote: support ? 'YEA' : 'NAY',
109
+ proposal,
110
+ note: 'You need LDO tokens to vote. Voting power is based on LDO balance at snapshot block.',
111
+ };
112
+ }
113
+
114
+ const wallet = getWallet();
115
+ const hash = await wallet.writeContract({
116
+ address: votingAddress,
117
+ abi: VOTING_ABI,
118
+ functionName: 'vote',
119
+ args: [BigInt(proposalId), support, false],
120
+ account,
121
+ });
122
+
123
+ return {
124
+ simulated: false,
125
+ mode: config.mode,
126
+ action: 'cast_vote',
127
+ proposalId,
128
+ vote: support ? 'YEA' : 'NAY',
129
+ txHash: hash,
130
+ };
131
+ }
@@ -0,0 +1,208 @@
1
+ import { z } from 'zod';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { getBalance, getRewards } from './balance.js';
4
+ import { stakeEth } from './stake.js';
5
+ import { requestWithdrawal, claimWithdrawals, getWithdrawalRequests, getWithdrawalStatus } from './unstake.js';
6
+ import { wrapSteth, unwrapWsteth, getConversionRate } from './wrap.js';
7
+ import { getProposals, getProposal, castVote } from './governance.js';
8
+ import { getL2Balance, getBridgeQuote, bridgeToEthereum, getBridgeStatus } from './bridge.js';
9
+
10
+ export function registerTools(server: McpServer, modeNote: string) {
11
+ // balance & info
12
+ server.tool(
13
+ 'get_balance',
14
+ `Get ETH, stETH, and wstETH balances for an address. Also returns current staking APR. ${modeNote}`,
15
+ { address: z.string().optional().describe('Ethereum address (defaults to configured wallet)') },
16
+ async ({ address }) => ({
17
+ content: [{ type: 'text', text: JSON.stringify(await getBalance(address), null, 2) }],
18
+ })
19
+ );
20
+
21
+ server.tool(
22
+ 'get_rewards',
23
+ 'Get staking reward history for an address over N days.',
24
+ {
25
+ address: z.string().optional().describe('Ethereum address (defaults to configured wallet)'),
26
+ days: z.number().int().min(1).max(365).optional().default(7).describe('Number of days to look back'),
27
+ },
28
+ async ({ address, days }) => ({
29
+ content: [{ type: 'text', text: JSON.stringify(await getRewards(address, days), null, 2) }],
30
+ })
31
+ );
32
+
33
+ server.tool(
34
+ 'get_conversion_rate',
35
+ 'Get current stETH <=> wstETH conversion rates.',
36
+ {},
37
+ async () => ({
38
+ content: [{ type: 'text', text: JSON.stringify(await getConversionRate(), null, 2) }],
39
+ })
40
+ );
41
+
42
+ // staking
43
+ server.tool(
44
+ 'stake_eth',
45
+ `Stake ETH to receive stETH. In simulation mode, dry_run defaults to true. ${modeNote}`,
46
+ {
47
+ amount_eth: z.string().describe('Amount of ETH to stake (e.g. "0.1")'),
48
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting. Always true in simulation mode unless set to false.'),
49
+ },
50
+ async ({ amount_eth, dry_run }) => ({
51
+ content: [{ type: 'text', text: JSON.stringify(await stakeEth(amount_eth, dry_run), null, 2) }],
52
+ })
53
+ );
54
+
55
+ // unstaking / withdrawals
56
+ server.tool(
57
+ 'request_withdrawal',
58
+ `Request withdrawal of stETH back to ETH via the Lido withdrawal queue. ${modeNote}`,
59
+ {
60
+ amount_steth: z.string().describe('Amount of stETH to withdraw (min 0.1, max 1000 per request)'),
61
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting.'),
62
+ },
63
+ async ({ amount_steth, dry_run }) => ({
64
+ content: [{ type: 'text', text: JSON.stringify(await requestWithdrawal(amount_steth, dry_run), null, 2) }],
65
+ })
66
+ );
67
+
68
+ server.tool(
69
+ 'claim_withdrawals',
70
+ `Claim finalized withdrawal requests and receive ETH. ${modeNote}`,
71
+ {
72
+ request_ids: z.array(z.string()).describe('Array of withdrawal request NFT IDs to claim'),
73
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting.'),
74
+ },
75
+ async ({ request_ids, dry_run }) => ({
76
+ content: [{ type: 'text', text: JSON.stringify(await claimWithdrawals(request_ids, dry_run), null, 2) }],
77
+ })
78
+ );
79
+
80
+ server.tool(
81
+ 'get_withdrawal_requests',
82
+ 'Get all pending withdrawal request IDs for an address.',
83
+ { address: z.string().optional().describe('Ethereum address (defaults to configured wallet)') },
84
+ async ({ address }) => ({
85
+ content: [{ type: 'text', text: JSON.stringify(await getWithdrawalRequests(address), null, 2) }],
86
+ })
87
+ );
88
+
89
+ server.tool(
90
+ 'get_withdrawal_status',
91
+ 'Check finalization status of withdrawal request IDs.',
92
+ { request_ids: z.array(z.string()).describe('Withdrawal request IDs to check') },
93
+ async ({ request_ids }) => ({
94
+ content: [{ type: 'text', text: JSON.stringify(await getWithdrawalStatus(request_ids), null, 2) }],
95
+ })
96
+ );
97
+
98
+ // wrap / unwrap
99
+ server.tool(
100
+ 'wrap_steth',
101
+ `Wrap stETH into wstETH (non-rebasing, better for DeFi). ${modeNote}`,
102
+ {
103
+ amount_steth: z.string().describe('Amount of stETH to wrap'),
104
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting.'),
105
+ },
106
+ async ({ amount_steth, dry_run }) => ({
107
+ content: [{ type: 'text', text: JSON.stringify(await wrapSteth(amount_steth, dry_run), null, 2) }],
108
+ })
109
+ );
110
+
111
+ server.tool(
112
+ 'unwrap_wsteth',
113
+ `Unwrap wstETH back to stETH. ${modeNote}`,
114
+ {
115
+ amount_wsteth: z.string().describe('Amount of wstETH to unwrap'),
116
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting.'),
117
+ },
118
+ async ({ amount_wsteth, dry_run }) => ({
119
+ content: [{ type: 'text', text: JSON.stringify(await unwrapWsteth(amount_wsteth, dry_run), null, 2) }],
120
+ })
121
+ );
122
+
123
+ // governance
124
+ server.tool(
125
+ 'get_proposals',
126
+ 'List recent Lido DAO governance proposals from the Aragon Voting contract.',
127
+ { count: z.number().int().min(1).max(20).optional().default(5).describe('Number of recent proposals to fetch') },
128
+ async ({ count }) => ({
129
+ content: [{ type: 'text', text: JSON.stringify(await getProposals(count), null, 2) }],
130
+ })
131
+ );
132
+
133
+ server.tool(
134
+ 'get_proposal',
135
+ 'Get detailed info on a specific Lido DAO governance proposal.',
136
+ { proposal_id: z.number().int().describe('Proposal/vote ID') },
137
+ async ({ proposal_id }) => ({
138
+ content: [{ type: 'text', text: JSON.stringify(await getProposal(proposal_id), null, 2) }],
139
+ })
140
+ );
141
+
142
+ server.tool(
143
+ 'cast_vote',
144
+ `Vote YEA or NAY on a Lido DAO governance proposal. Requires LDO tokens. ${modeNote}`,
145
+ {
146
+ proposal_id: z.number().int().describe('Proposal/vote ID to vote on'),
147
+ support: z.boolean().describe('true = YEA (support), false = NAY (against)'),
148
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting.'),
149
+ },
150
+ async ({ proposal_id, support, dry_run }) => ({
151
+ content: [{ type: 'text', text: JSON.stringify(await castVote(proposal_id, support, dry_run), null, 2) }],
152
+ })
153
+ );
154
+
155
+ // L2 bridging
156
+ server.tool(
157
+ 'get_l2_balance',
158
+ 'Get ETH and wstETH balances on Base or Arbitrum. Only works in live (mainnet) mode.',
159
+ {
160
+ source_chain: z.enum(['base', 'arbitrum']).describe('L2 chain to query'),
161
+ address: z.string().optional().describe('Address to check (defaults to configured wallet)'),
162
+ },
163
+ async ({ source_chain, address }) => ({
164
+ content: [{ type: 'text', text: JSON.stringify(await getL2Balance(source_chain, address), null, 2) }],
165
+ })
166
+ );
167
+
168
+ server.tool(
169
+ 'get_bridge_quote',
170
+ 'Get a quote for bridging ETH or wstETH from an L2 to Ethereum L1 via LI.FI. Mainnet only.',
171
+ {
172
+ source_chain: z.enum(['base', 'arbitrum']).describe('L2 chain to bridge from'),
173
+ token: z.enum(['ETH', 'wstETH']).describe('Token to bridge'),
174
+ amount: z.string().describe('Amount to bridge (e.g. "0.1")'),
175
+ to_token: z.enum(['ETH', 'wstETH']).optional().describe('Token to receive on L1 (default ETH)'),
176
+ },
177
+ async ({ source_chain, token, amount, to_token }) => ({
178
+ content: [{ type: 'text', text: JSON.stringify(await getBridgeQuote(source_chain, token, amount, to_token), null, 2) }],
179
+ })
180
+ );
181
+
182
+ server.tool(
183
+ 'bridge_to_ethereum',
184
+ `Bridge ETH or wstETH from Base/Arbitrum to Ethereum L1 via LI.FI. ${modeNote}`,
185
+ {
186
+ source_chain: z.enum(['base', 'arbitrum']).describe('L2 chain to bridge from'),
187
+ token: z.enum(['ETH', 'wstETH']).describe('Token to bridge'),
188
+ amount: z.string().describe('Amount to bridge'),
189
+ to_token: z.enum(['ETH', 'wstETH']).optional().describe('Token to receive on L1'),
190
+ dry_run: z.boolean().optional().describe('Simulate without broadcasting'),
191
+ },
192
+ async ({ source_chain, token, amount, to_token, dry_run }) => ({
193
+ content: [{ type: 'text', text: JSON.stringify(await bridgeToEthereum(source_chain, token, amount, to_token, dry_run), null, 2) }],
194
+ })
195
+ );
196
+
197
+ server.tool(
198
+ 'get_bridge_status',
199
+ 'Check the status of an in-progress bridge transaction via LI.FI. Mainnet only.',
200
+ {
201
+ tx_hash: z.string().describe('Bridge transaction hash on the L2'),
202
+ source_chain: z.enum(['base', 'arbitrum']).describe('L2 chain the bridge was sent from'),
203
+ },
204
+ async ({ tx_hash, source_chain }) => ({
205
+ content: [{ type: 'text', text: JSON.stringify(await getBridgeStatus(tx_hash, source_chain), null, 2) }],
206
+ })
207
+ );
208
+ }
@@ -0,0 +1,67 @@
1
+ import { parseEther, formatEther } from 'viem';
2
+ import { getSDK, publicClient } from '../sdk.js';
3
+ import { getWallet, getAddress } from '../wallet.js';
4
+ import { config } from '../config.js';
5
+
6
+ // stETH (Lido) contract addresses
7
+ const LIDO_ADDRESSES: Record<number, `0x${string}`> = {
8
+ 1: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84',
9
+ 17000: '0x3F1c547b21f65e10480dE3ad8E19fAAC46C95034',
10
+ };
11
+
12
+ const SUBMIT_ABI = [
13
+ {
14
+ name: 'submit',
15
+ type: 'function',
16
+ inputs: [{ name: '_referral', type: 'address' }],
17
+ outputs: [{ type: 'uint256' }],
18
+ stateMutability: 'payable',
19
+ },
20
+ ] as const;
21
+
22
+ export async function stakeEth(amountEth: string, dryRun?: boolean) {
23
+ const sdk = getSDK();
24
+ const value = parseEther(amountEth);
25
+ const account = getAddress();
26
+ const lidoAddress = LIDO_ADDRESSES[config.chainId];
27
+
28
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
29
+
30
+ if (shouldDryRun) {
31
+ const gas = await publicClient.estimateContractGas({
32
+ address: lidoAddress,
33
+ abi: SUBMIT_ABI,
34
+ functionName: 'submit',
35
+ args: [config.referralAddress],
36
+ value,
37
+ account,
38
+ });
39
+
40
+ return {
41
+ simulated: true,
42
+ mode: config.mode,
43
+ action: 'stake',
44
+ amountEth,
45
+ estimatedGas: gas.toString(),
46
+ expectedStETH: amountEth,
47
+ note: 'stETH rebases daily — your balance grows automatically after staking.',
48
+ };
49
+ }
50
+
51
+ const wallet = getWallet();
52
+ const tx = await sdk.stake.stakeEth({
53
+ value,
54
+ account: { address: account } as any,
55
+ callback: ({ stage, payload }: any) => {},
56
+ });
57
+
58
+ return {
59
+ simulated: false,
60
+ mode: config.mode,
61
+ action: 'stake',
62
+ amountEth,
63
+ txHash: (tx as any).hash,
64
+ stethReceived: formatEther((tx as any).result?.stethReceived ?? 0n),
65
+ sharesReceived: formatEther((tx as any).result?.sharesReceived ?? 0n),
66
+ };
67
+ }
@@ -0,0 +1,103 @@
1
+ import { parseEther, formatEther } from 'viem';
2
+ import { getSDK, publicClient } from '../sdk.js';
3
+ import { getAddress } from '../wallet.js';
4
+ import { config } from '../config.js';
5
+
6
+ export async function requestWithdrawal(amountSteth: string, dryRun?: boolean) {
7
+ const sdk = getSDK();
8
+ const amount = parseEther(amountSteth);
9
+ const account = getAddress();
10
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
11
+
12
+ if (shouldDryRun) {
13
+ return {
14
+ simulated: true,
15
+ mode: config.mode,
16
+ action: 'request_withdrawal',
17
+ amountSteth,
18
+ note: 'Withdrawal requests enter a queue. Finalization can take hours to days depending on validator exits. You will receive an ERC-721 NFT representing your position in the queue.',
19
+ minWithdrawal: '0.1 stETH',
20
+ maxWithdrawal: '1000 stETH per request',
21
+ };
22
+ }
23
+
24
+ const tx = await sdk.withdrawals.requestWithdrawals({
25
+ amounts: [amount],
26
+ account: { address: account } as any,
27
+ callback: ({ stage, payload }: any) => {},
28
+ });
29
+
30
+ return {
31
+ simulated: false,
32
+ mode: config.mode,
33
+ action: 'request_withdrawal',
34
+ amountSteth,
35
+ txHash: (tx as any).hash,
36
+ requestIds: (tx as any).result?.requestIds?.map((id: bigint) => id.toString()),
37
+ note: 'Check withdrawal status with get_withdrawal_status. Claim when finalized.',
38
+ };
39
+ }
40
+
41
+ export async function claimWithdrawals(requestIds: string[], dryRun?: boolean) {
42
+ const sdk = getSDK();
43
+ const ids = requestIds.map(BigInt);
44
+ const account = getAddress();
45
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
46
+
47
+ if (shouldDryRun) {
48
+ return {
49
+ simulated: true,
50
+ mode: config.mode,
51
+ action: 'claim_withdrawals',
52
+ requestIds,
53
+ note: 'Claims can only be made after requests are finalized. Use get_withdrawal_status to check.',
54
+ };
55
+ }
56
+
57
+ const tx = await sdk.withdrawals.claimWithdrawals({
58
+ requestsIds: ids,
59
+ account: { address: account } as any,
60
+ callback: ({ stage, payload }: any) => {},
61
+ });
62
+
63
+ return {
64
+ simulated: false,
65
+ mode: config.mode,
66
+ action: 'claim_withdrawals',
67
+ requestIds,
68
+ txHash: (tx as any).hash,
69
+ };
70
+ }
71
+
72
+ export async function getWithdrawalRequests(address?: string) {
73
+ const sdk = getSDK();
74
+ const addr = (address ?? getAddress()) as `0x${string}`;
75
+
76
+ const requests = await sdk.withdrawals.getWithdrawalRequests({ account: addr });
77
+
78
+ return {
79
+ address: addr,
80
+ mode: config.mode,
81
+ requests: requests.map((id: bigint) => id.toString()),
82
+ count: requests.length,
83
+ };
84
+ }
85
+
86
+ export async function getWithdrawalStatus(requestIds: string[]) {
87
+ const sdk = getSDK();
88
+ const ids = requestIds.map(BigInt);
89
+
90
+ const statuses = await sdk.withdrawals.getWithdrawalStatus({ requestsIds: ids });
91
+
92
+ return {
93
+ mode: config.mode,
94
+ statuses: statuses.map((s: any, i: number) => ({
95
+ requestId: requestIds[i],
96
+ amountOfStETH: formatEther(s.amountOfStETH ?? 0n),
97
+ amountOfShares: formatEther(s.amountOfShares ?? 0n),
98
+ owner: s.owner,
99
+ isFinalized: s.isFinalized,
100
+ isClaimed: s.isClaimed,
101
+ })),
102
+ };
103
+ }
@@ -0,0 +1,91 @@
1
+ import { parseEther, formatEther } from 'viem';
2
+ import { getSDK } from '../sdk.js';
3
+ import { getAddress } from '../wallet.js';
4
+ import { config } from '../config.js';
5
+
6
+ export async function wrapSteth(amountSteth: string, dryRun?: boolean) {
7
+ const sdk = getSDK();
8
+ const amount = parseEther(amountSteth);
9
+ const account = getAddress();
10
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
11
+
12
+ // Get current wstETH conversion rate
13
+ const wstethPerSteth = await sdk.core.convertStethToWsteth(amount);
14
+
15
+ if (shouldDryRun) {
16
+ return {
17
+ simulated: true,
18
+ mode: config.mode,
19
+ action: 'wrap_steth',
20
+ amountSteth,
21
+ expectedWstETH: formatEther(wstethPerSteth),
22
+ note: 'wstETH is a non-rebasing wrapper. Its balance stays fixed while its value grows. Better for DeFi integrations.',
23
+ };
24
+ }
25
+
26
+ const tx = await sdk.wrap.wrapSteth({
27
+ value: amount,
28
+ account: { address: account } as any,
29
+ callback: ({ stage, payload }: any) => {},
30
+ });
31
+
32
+ return {
33
+ simulated: false,
34
+ mode: config.mode,
35
+ action: 'wrap_steth',
36
+ amountSteth,
37
+ txHash: (tx as any).hash,
38
+ wstethReceived: formatEther((tx as any).result?.wstethReceived ?? 0n),
39
+ };
40
+ }
41
+
42
+ export async function unwrapWsteth(amountWsteth: string, dryRun?: boolean) {
43
+ const sdk = getSDK();
44
+ const amount = parseEther(amountWsteth);
45
+ const account = getAddress();
46
+ const shouldDryRun = config.isSimulation ? (dryRun !== false) : !!dryRun;
47
+
48
+ const stethAmount = await sdk.core.convertWstethToSteth(amount);
49
+
50
+ if (shouldDryRun) {
51
+ return {
52
+ simulated: true,
53
+ mode: config.mode,
54
+ action: 'unwrap_wsteth',
55
+ amountWsteth,
56
+ expectedStETH: formatEther(stethAmount),
57
+ note: 'Unwrapping gives you rebasing stETH back. Your balance will update daily with rewards.',
58
+ };
59
+ }
60
+
61
+ const tx = await sdk.wrap.unwrapWsteth({
62
+ value: amount,
63
+ account: { address: account } as any,
64
+ callback: ({ stage, payload }: any) => {},
65
+ });
66
+
67
+ return {
68
+ simulated: false,
69
+ mode: config.mode,
70
+ action: 'unwrap_wsteth',
71
+ amountWsteth,
72
+ txHash: (tx as any).hash,
73
+ stethReceived: formatEther((tx as any).result?.stethReceived ?? 0n),
74
+ };
75
+ }
76
+
77
+ export async function getConversionRate() {
78
+ const sdk = getSDK();
79
+ const oneEther = parseEther('1');
80
+ const [wstethPerSteth, stethPerWsteth] = await Promise.all([
81
+ sdk.core.convertStethToWsteth(oneEther),
82
+ sdk.core.convertWstethToSteth(oneEther),
83
+ ]);
84
+
85
+ return {
86
+ mode: config.mode,
87
+ '1_stETH_in_wstETH': formatEther(wstethPerSteth),
88
+ '1_wstETH_in_stETH': formatEther(stethPerWsteth),
89
+ note: 'wstETH/stETH ratio increases over time as staking rewards accumulate.',
90
+ };
91
+ }
package/src/wallet.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { createWalletClient, createPublicClient, http, type WalletClient, type PublicClient } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import { config, L2_CHAINS } from './config.js';
4
+ import type { L2Chain } from './config.js';
5
+
6
+ let _wallet: WalletClient | null = null;
7
+
8
+ export function getWallet(): WalletClient {
9
+ if (_wallet) return _wallet;
10
+
11
+ const pk = process.env.PRIVATE_KEY;
12
+ if (!pk) throw new Error('PRIVATE_KEY env var is required');
13
+
14
+ const account = privateKeyToAccount(pk as `0x${string}`);
15
+ _wallet = createWalletClient({
16
+ account,
17
+ chain: config.chain,
18
+ transport: http(config.rpcUrl),
19
+ });
20
+
21
+ return _wallet;
22
+ }
23
+
24
+ export function getAddress(): `0x${string}` {
25
+ const pk = process.env.PRIVATE_KEY;
26
+ if (!pk) throw new Error('PRIVATE_KEY env var is required');
27
+ return privateKeyToAccount(pk as `0x${string}`).address;
28
+ }
29
+
30
+ const _l2Wallets: Partial<Record<L2Chain, WalletClient>> = {};
31
+ const _l2Clients: Partial<Record<L2Chain, PublicClient>> = {};
32
+
33
+ export function getL2Wallet(chain: L2Chain): WalletClient {
34
+ if (_l2Wallets[chain]) return _l2Wallets[chain]!;
35
+ const pk = process.env.PRIVATE_KEY;
36
+ if (!pk) throw new Error('PRIVATE_KEY env var is required');
37
+ const account = privateKeyToAccount(pk as `0x${string}`);
38
+ const cfg = L2_CHAINS[chain];
39
+ const w = createWalletClient({ account, chain: cfg.viemChain, transport: http(cfg.defaultRpc) });
40
+ _l2Wallets[chain] = w;
41
+ return w;
42
+ }
43
+
44
+ export function getL2PublicClient(chain: L2Chain): PublicClient {
45
+ if (_l2Clients[chain]) return _l2Clients[chain]!;
46
+ const cfg = L2_CHAINS[chain];
47
+ const c = createPublicClient({ chain: cfg.viemChain, transport: http(cfg.defaultRpc) });
48
+ _l2Clients[chain] = c;
49
+ return c;
50
+ }