@moly-mcp/lido 1.0.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/dist/bin.js +260 -0
- package/dist/chunk-PIFEXJ56.js +59 -0
- package/dist/server/index.js +688 -0
- package/package.json +34 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
configExists,
|
|
4
|
+
deleteConfig,
|
|
5
|
+
getConfigPath,
|
|
6
|
+
loadConfig,
|
|
7
|
+
redactedConfig,
|
|
8
|
+
saveConfig
|
|
9
|
+
} from "./chunk-PIFEXJ56.js";
|
|
10
|
+
|
|
11
|
+
// src/setup/wizard.ts
|
|
12
|
+
import {
|
|
13
|
+
intro,
|
|
14
|
+
outro,
|
|
15
|
+
select,
|
|
16
|
+
text,
|
|
17
|
+
password,
|
|
18
|
+
confirm,
|
|
19
|
+
note,
|
|
20
|
+
cancel,
|
|
21
|
+
isCancel
|
|
22
|
+
} from "@clack/prompts";
|
|
23
|
+
function bail(value) {
|
|
24
|
+
cancel("Setup cancelled.");
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
function check(value) {
|
|
28
|
+
if (isCancel(value)) bail(value);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function clientSnippet(client) {
|
|
32
|
+
const entry = `"moly": { "command": "npx", "args": ["@moly/lido", "--server"] }`;
|
|
33
|
+
switch (client) {
|
|
34
|
+
case "claude-desktop":
|
|
35
|
+
return `Add to ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
36
|
+
(macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows):
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
${entry}
|
|
41
|
+
}
|
|
42
|
+
}`;
|
|
43
|
+
case "cursor":
|
|
44
|
+
return `Add to ~/.cursor/mcp.json (global) or .cursor/mcp.json (project):
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
${entry}
|
|
49
|
+
}
|
|
50
|
+
}`;
|
|
51
|
+
case "windsurf":
|
|
52
|
+
return `Add to ~/.codeium/windsurf/mcp_config.json:
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
${entry}
|
|
57
|
+
}
|
|
58
|
+
}`;
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
var CLAUDE_MODELS = [
|
|
64
|
+
{ value: "claude-sonnet-4-6", label: "claude-sonnet-4-6 (fast, capable \u2014 recommended)" },
|
|
65
|
+
{ value: "claude-opus-4-6", label: "claude-opus-4-6 (most capable)" },
|
|
66
|
+
{ value: "claude-haiku-4-5-20251001", label: "claude-haiku-4-5-20251001 (fastest, cheapest)" },
|
|
67
|
+
{ value: "__custom__", label: "Enter custom model ID" }
|
|
68
|
+
];
|
|
69
|
+
var GEMINI_MODELS = [
|
|
70
|
+
{ value: "gemini-2.0-flash", label: "gemini-2.0-flash (fast, recommended)" },
|
|
71
|
+
{ value: "gemini-1.5-pro", label: "gemini-1.5-pro (capable)" },
|
|
72
|
+
{ value: "gemini-1.5-flash", label: "gemini-1.5-flash (fast)" },
|
|
73
|
+
{ value: "__custom__", label: "Enter custom model ID" }
|
|
74
|
+
];
|
|
75
|
+
var OPENROUTER_MODELS = [
|
|
76
|
+
{ value: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
|
|
77
|
+
{ value: "anthropic/claude-opus-4-6", label: "anthropic/claude-opus-4-6" },
|
|
78
|
+
{ value: "google/gemini-2.0-flash", label: "google/gemini-2.0-flash" },
|
|
79
|
+
{ value: "openai/gpt-4o", label: "openai/gpt-4o" },
|
|
80
|
+
{ value: "__custom__", label: "Enter custom model ID (any OpenRouter slug)" }
|
|
81
|
+
];
|
|
82
|
+
async function pickModel(provider) {
|
|
83
|
+
const models = provider === "anthropic" ? CLAUDE_MODELS : provider === "google" ? GEMINI_MODELS : OPENROUTER_MODELS;
|
|
84
|
+
const picked = check(
|
|
85
|
+
await select({
|
|
86
|
+
message: "Model?",
|
|
87
|
+
options: models
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
if (picked === "__custom__") {
|
|
91
|
+
return check(
|
|
92
|
+
await text({ message: "Enter model ID:", placeholder: "provider/model-name" })
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return picked;
|
|
96
|
+
}
|
|
97
|
+
async function runWizard() {
|
|
98
|
+
intro(" Moly \u2014 Lido MCP Server \u2B21");
|
|
99
|
+
const network = check(
|
|
100
|
+
await select({
|
|
101
|
+
message: "Which network?",
|
|
102
|
+
options: [
|
|
103
|
+
{ value: "hoodi", label: "Hoodi Testnet (chain 560048 \u2014 safe for testing)" },
|
|
104
|
+
{ value: "mainnet", label: "Ethereum Mainnet (chain 1 \u2014 real assets)" }
|
|
105
|
+
]
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
const rpcInput = check(
|
|
109
|
+
await text({
|
|
110
|
+
message: "Custom RPC URL? (optional \u2014 leave blank to use public RPC)",
|
|
111
|
+
placeholder: "https://..."
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
const rpc = rpcInput.trim() || null;
|
|
115
|
+
const mode = check(
|
|
116
|
+
await select({
|
|
117
|
+
message: "Mode?",
|
|
118
|
+
options: [
|
|
119
|
+
{ value: "simulation", label: "Simulation (dry-run \u2014 estimates only, nothing sent)" },
|
|
120
|
+
{ value: "live", label: "Live (real on-chain transactions)" }
|
|
121
|
+
]
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
let privateKey = null;
|
|
125
|
+
const wantsKey = check(
|
|
126
|
+
await confirm({
|
|
127
|
+
message: "Add a private key? (needed for Live mode \u2014 stored locally with chmod 600)",
|
|
128
|
+
initialValue: mode === "live"
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
if (wantsKey) {
|
|
132
|
+
const pk = check(
|
|
133
|
+
await password({
|
|
134
|
+
message: "Private key (0x...):",
|
|
135
|
+
mask: "*"
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
privateKey = pk.trim() || null;
|
|
139
|
+
}
|
|
140
|
+
const providerChoice = check(
|
|
141
|
+
await select({
|
|
142
|
+
message: "AI Provider? (optional \u2014 used for built-in chat + config snippet)",
|
|
143
|
+
options: [
|
|
144
|
+
{ value: "none", label: "None / Skip" },
|
|
145
|
+
{ value: "anthropic", label: "Anthropic (Claude)" },
|
|
146
|
+
{ value: "google", label: "Google (Gemini)" },
|
|
147
|
+
{ value: "openrouter", label: "OpenRouter (access any model with one key)" }
|
|
148
|
+
]
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
let ai = null;
|
|
152
|
+
if (providerChoice !== "none") {
|
|
153
|
+
const provider = providerChoice;
|
|
154
|
+
const apiKey = check(
|
|
155
|
+
await password({
|
|
156
|
+
message: "API Key:",
|
|
157
|
+
mask: "*"
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
const model = await pickModel(provider);
|
|
161
|
+
ai = { provider, apiKey: apiKey.trim(), model };
|
|
162
|
+
}
|
|
163
|
+
const clientChoice = check(
|
|
164
|
+
await select({
|
|
165
|
+
message: "Which AI client are you using? (for config snippet)",
|
|
166
|
+
options: [
|
|
167
|
+
{ value: "claude-desktop", label: "Claude Desktop" },
|
|
168
|
+
{ value: "cursor", label: "Cursor" },
|
|
169
|
+
{ value: "windsurf", label: "Windsurf / Codeium" },
|
|
170
|
+
{ value: "none", label: "Skip" }
|
|
171
|
+
]
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
const cfg = {
|
|
175
|
+
network,
|
|
176
|
+
mode,
|
|
177
|
+
rpc,
|
|
178
|
+
privateKey,
|
|
179
|
+
ai,
|
|
180
|
+
setupComplete: true
|
|
181
|
+
};
|
|
182
|
+
saveConfig(cfg);
|
|
183
|
+
note(`Config saved \u2192 ${getConfigPath()}`, "Saved");
|
|
184
|
+
const snippet = clientSnippet(clientChoice);
|
|
185
|
+
if (snippet) {
|
|
186
|
+
note(snippet, "Add to your AI client config");
|
|
187
|
+
}
|
|
188
|
+
outro(`Starting Moly MCP Server... ${mode} \xB7 ${network} \xB7 ready`);
|
|
189
|
+
return cfg;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/bin.ts
|
|
193
|
+
var args = process.argv.slice(2);
|
|
194
|
+
var command = args[0];
|
|
195
|
+
async function startServer() {
|
|
196
|
+
await import("./server/index.js");
|
|
197
|
+
}
|
|
198
|
+
async function main() {
|
|
199
|
+
switch (command) {
|
|
200
|
+
// ── moly setup ────────────────────────────────────────────────────
|
|
201
|
+
case "setup": {
|
|
202
|
+
await runWizard();
|
|
203
|
+
await startServer();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
// ── moly config ───────────────────────────────────────────────────
|
|
207
|
+
case "config": {
|
|
208
|
+
if (!configExists()) {
|
|
209
|
+
console.log("No config found. Run: npx @moly/lido");
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const cfg = loadConfig();
|
|
213
|
+
console.log("\nMoly configuration (~/.moly/config.json):\n");
|
|
214
|
+
console.log(JSON.stringify(redactedConfig(cfg), null, 2));
|
|
215
|
+
console.log("\nTo change settings: moly setup");
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
// ── moly reset ────────────────────────────────────────────────────
|
|
219
|
+
case "reset": {
|
|
220
|
+
if (!configExists()) {
|
|
221
|
+
console.log("No config found \u2014 nothing to reset.");
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
deleteConfig();
|
|
225
|
+
console.log("Config deleted. Run: npx @moly/lido to set up again.");
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
// ── moly --server (force-start, used in AI client configs) ────────
|
|
229
|
+
case "--server": {
|
|
230
|
+
if (!configExists()) {
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
"ERROR: No config found. Run: npx @moly/lido to set up first.\n"
|
|
233
|
+
);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
await startServer();
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
// ── moly (no args) — wizard if first run, else start server ───────
|
|
240
|
+
default: {
|
|
241
|
+
if (!configExists()) {
|
|
242
|
+
const cfg = await runWizard();
|
|
243
|
+
await startServer();
|
|
244
|
+
} else {
|
|
245
|
+
const cfg = loadConfig();
|
|
246
|
+
process.stderr.write(
|
|
247
|
+
`@moly/lido \u2014 starting MCP server (${cfg.mode} \xB7 ${cfg.network})
|
|
248
|
+
`
|
|
249
|
+
);
|
|
250
|
+
await startServer();
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
main().catch((err) => {
|
|
257
|
+
process.stderr.write(`Error: ${err.message}
|
|
258
|
+
`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config/store.ts
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
var CONFIG_DIR = join(homedir(), ".moly");
|
|
8
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
9
|
+
function configExists() {
|
|
10
|
+
return existsSync(CONFIG_PATH);
|
|
11
|
+
}
|
|
12
|
+
function loadConfig() {
|
|
13
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
14
|
+
throw new Error("No config found. Run: npx @moly/lido (or: moly setup)");
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(`Failed to parse config at ${CONFIG_PATH}. Run: moly reset`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveConfig(cfg) {
|
|
23
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf-8");
|
|
27
|
+
try {
|
|
28
|
+
chmodSync(CONFIG_PATH, 384);
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function deleteConfig() {
|
|
33
|
+
if (existsSync(CONFIG_PATH)) unlinkSync(CONFIG_PATH);
|
|
34
|
+
}
|
|
35
|
+
function getConfigPath() {
|
|
36
|
+
return CONFIG_PATH;
|
|
37
|
+
}
|
|
38
|
+
function redactedConfig(cfg) {
|
|
39
|
+
return {
|
|
40
|
+
network: cfg.network,
|
|
41
|
+
mode: cfg.mode,
|
|
42
|
+
rpc: cfg.rpc ?? "(default public RPC)",
|
|
43
|
+
privateKey: cfg.privateKey ? "*** configured" : "(not set)",
|
|
44
|
+
ai: cfg.ai ? {
|
|
45
|
+
provider: cfg.ai.provider,
|
|
46
|
+
model: cfg.ai.model,
|
|
47
|
+
apiKey: "*** configured"
|
|
48
|
+
} : "(not configured)"
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
configExists,
|
|
54
|
+
loadConfig,
|
|
55
|
+
saveConfig,
|
|
56
|
+
deleteConfig,
|
|
57
|
+
getConfigPath,
|
|
58
|
+
redactedConfig
|
|
59
|
+
};
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
loadConfig,
|
|
4
|
+
redactedConfig,
|
|
5
|
+
saveConfig
|
|
6
|
+
} from "../chunk-PIFEXJ56.js";
|
|
7
|
+
|
|
8
|
+
// src/server/index.ts
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// src/tools/balance.ts
|
|
14
|
+
import { formatEther } from "viem";
|
|
15
|
+
|
|
16
|
+
// src/server/runtime.ts
|
|
17
|
+
import { createPublicClient, createWalletClient, http, defineChain } from "viem";
|
|
18
|
+
import { mainnet } from "viem/chains";
|
|
19
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
20
|
+
import { LidoSDK } from "@lidofinance/lido-ethereum-sdk";
|
|
21
|
+
|
|
22
|
+
// src/config/types.ts
|
|
23
|
+
var CHAIN_CONFIG = {
|
|
24
|
+
hoodi: {
|
|
25
|
+
chainId: 560048,
|
|
26
|
+
stETH: "0x3508A952176b3c15387C97BE809eaffB1982176a",
|
|
27
|
+
wstETH: "0x7E99eE3C66636DE415D2d7C880938F2f40f94De4",
|
|
28
|
+
voting: "0x49B3512c44891bef83F8967d075121Bd1b07a01B",
|
|
29
|
+
defaultRpc: "https://hoodi.drpc.org",
|
|
30
|
+
name: "Hoodi Testnet"
|
|
31
|
+
},
|
|
32
|
+
mainnet: {
|
|
33
|
+
chainId: 1,
|
|
34
|
+
stETH: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84",
|
|
35
|
+
wstETH: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0",
|
|
36
|
+
voting: "0x2e59A20f205bB85a89C53f1936454680651E618e",
|
|
37
|
+
defaultRpc: "https://eth.llamarpc.com",
|
|
38
|
+
name: "Ethereum Mainnet"
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/server/runtime.ts
|
|
43
|
+
var hoodi = defineChain({
|
|
44
|
+
id: 560048,
|
|
45
|
+
name: "Hoodi Testnet",
|
|
46
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
47
|
+
rpcUrls: { default: { http: ["https://hoodi.drpc.org"] } }
|
|
48
|
+
});
|
|
49
|
+
var _runtime = null;
|
|
50
|
+
function buildRuntime() {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
const chainCfg = CHAIN_CONFIG[config.network];
|
|
53
|
+
const rpcUrl = config.rpc ?? chainCfg.defaultRpc;
|
|
54
|
+
const viemChain = config.network === "mainnet" ? mainnet : hoodi;
|
|
55
|
+
const publicClient = createPublicClient({
|
|
56
|
+
chain: viemChain,
|
|
57
|
+
transport: http(rpcUrl)
|
|
58
|
+
});
|
|
59
|
+
const sdk = new LidoSDK({
|
|
60
|
+
chainId: chainCfg.chainId,
|
|
61
|
+
rpcProvider: publicClient
|
|
62
|
+
});
|
|
63
|
+
let _wallet = null;
|
|
64
|
+
function getWallet() {
|
|
65
|
+
if (_wallet) return _wallet;
|
|
66
|
+
const pk = config.privateKey;
|
|
67
|
+
if (!pk) throw new Error("No private key configured. Run: moly setup");
|
|
68
|
+
const account = privateKeyToAccount(pk);
|
|
69
|
+
_wallet = createWalletClient({
|
|
70
|
+
account,
|
|
71
|
+
chain: viemChain,
|
|
72
|
+
transport: http(rpcUrl)
|
|
73
|
+
});
|
|
74
|
+
return _wallet;
|
|
75
|
+
}
|
|
76
|
+
function getAddress() {
|
|
77
|
+
const pk = config.privateKey;
|
|
78
|
+
if (!pk) throw new Error("No private key configured. Run: moly setup");
|
|
79
|
+
return privateKeyToAccount(pk).address;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
config,
|
|
83
|
+
chainAddresses: chainCfg,
|
|
84
|
+
publicClient,
|
|
85
|
+
sdk,
|
|
86
|
+
getWallet,
|
|
87
|
+
getAddress,
|
|
88
|
+
reload() {
|
|
89
|
+
_runtime = null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function getRuntime() {
|
|
94
|
+
if (!_runtime) _runtime = buildRuntime();
|
|
95
|
+
return _runtime;
|
|
96
|
+
}
|
|
97
|
+
function applySettingsUpdate(patch) {
|
|
98
|
+
const current = loadConfig();
|
|
99
|
+
if (patch.network !== void 0) current.network = patch.network;
|
|
100
|
+
if (patch.mode !== void 0) current.mode = patch.mode;
|
|
101
|
+
if (patch.rpc !== void 0) current.rpc = patch.rpc;
|
|
102
|
+
if (patch.model !== void 0 && current.ai) current.ai.model = patch.model;
|
|
103
|
+
saveConfig(current);
|
|
104
|
+
_runtime = null;
|
|
105
|
+
return current;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/tools/balance.ts
|
|
109
|
+
async function getBalance(address) {
|
|
110
|
+
const rt = getRuntime();
|
|
111
|
+
const addr = address ?? rt.getAddress();
|
|
112
|
+
const [eth, steth, wsteth] = await Promise.all([
|
|
113
|
+
rt.sdk.core.balanceETH(addr),
|
|
114
|
+
rt.sdk.steth.balance(addr),
|
|
115
|
+
rt.sdk.wsteth.balance(addr)
|
|
116
|
+
]);
|
|
117
|
+
return {
|
|
118
|
+
address: addr,
|
|
119
|
+
mode: rt.config.mode,
|
|
120
|
+
network: rt.chainAddresses.name,
|
|
121
|
+
balances: {
|
|
122
|
+
eth: formatEther(eth),
|
|
123
|
+
stETH: formatEther(steth),
|
|
124
|
+
wstETH: formatEther(wsteth)
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function getRewards(address, days = 7) {
|
|
129
|
+
const rt = getRuntime();
|
|
130
|
+
const addr = address ?? rt.getAddress();
|
|
131
|
+
const rewards = await rt.sdk.rewards.getRewardsFromChain({
|
|
132
|
+
address: addr,
|
|
133
|
+
stepBlock: 1e3,
|
|
134
|
+
back: { days: BigInt(days) }
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
address: addr,
|
|
138
|
+
period: `${days} days`,
|
|
139
|
+
totalRewards: formatEther(rewards.totalRewards),
|
|
140
|
+
baseBalance: formatEther(rewards.baseBalance),
|
|
141
|
+
rewards: rewards.rewards.slice(0, 10).map((e) => ({
|
|
142
|
+
type: e.type,
|
|
143
|
+
change: formatEther(e.change),
|
|
144
|
+
balance: formatEther(e.balance)
|
|
145
|
+
}))
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/tools/stake.ts
|
|
150
|
+
import { parseEther, formatEther as formatEther2 } from "viem";
|
|
151
|
+
var SUBMIT_ABI = [
|
|
152
|
+
{
|
|
153
|
+
name: "submit",
|
|
154
|
+
type: "function",
|
|
155
|
+
inputs: [{ name: "_referral", type: "address" }],
|
|
156
|
+
outputs: [{ type: "uint256" }],
|
|
157
|
+
stateMutability: "payable"
|
|
158
|
+
}
|
|
159
|
+
];
|
|
160
|
+
var REFERRAL = "0x0000000000000000000000000000000000000000";
|
|
161
|
+
async function stakeEth(amountEth, dryRun) {
|
|
162
|
+
const rt = getRuntime();
|
|
163
|
+
const value = parseEther(amountEth);
|
|
164
|
+
const account = rt.getAddress();
|
|
165
|
+
const lidoAddress = rt.chainAddresses.stETH;
|
|
166
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
167
|
+
if (shouldDryRun) {
|
|
168
|
+
let estimatedGas = "unavailable";
|
|
169
|
+
try {
|
|
170
|
+
const gas = await rt.publicClient.estimateContractGas({
|
|
171
|
+
address: lidoAddress,
|
|
172
|
+
abi: SUBMIT_ABI,
|
|
173
|
+
functionName: "submit",
|
|
174
|
+
args: [REFERRAL],
|
|
175
|
+
value,
|
|
176
|
+
account: "0x0000000000000000000000000000000000000001"
|
|
177
|
+
});
|
|
178
|
+
estimatedGas = gas.toString();
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
simulated: true,
|
|
183
|
+
mode: rt.config.mode,
|
|
184
|
+
network: rt.chainAddresses.name,
|
|
185
|
+
action: "stake",
|
|
186
|
+
amountEth,
|
|
187
|
+
estimatedGas,
|
|
188
|
+
expectedStETH: amountEth,
|
|
189
|
+
note: "stETH rebases daily \u2014 your balance grows automatically after staking."
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const tx = await rt.sdk.stake.stakeEth({
|
|
193
|
+
value,
|
|
194
|
+
account: { address: account },
|
|
195
|
+
callback: () => {
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
simulated: false,
|
|
200
|
+
mode: rt.config.mode,
|
|
201
|
+
network: rt.chainAddresses.name,
|
|
202
|
+
action: "stake",
|
|
203
|
+
amountEth,
|
|
204
|
+
txHash: tx.hash,
|
|
205
|
+
stethReceived: formatEther2(tx.result?.stethReceived ?? 0n),
|
|
206
|
+
sharesReceived: formatEther2(tx.result?.sharesReceived ?? 0n)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/tools/unstake.ts
|
|
211
|
+
import { parseEther as parseEther2, formatEther as formatEther3 } from "viem";
|
|
212
|
+
async function requestWithdrawal(amountSteth, dryRun) {
|
|
213
|
+
const rt = getRuntime();
|
|
214
|
+
const amount = parseEther2(amountSteth);
|
|
215
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
216
|
+
if (shouldDryRun) {
|
|
217
|
+
return {
|
|
218
|
+
simulated: true,
|
|
219
|
+
mode: rt.config.mode,
|
|
220
|
+
network: rt.chainAddresses.name,
|
|
221
|
+
action: "request_withdrawal",
|
|
222
|
+
amountSteth,
|
|
223
|
+
minWithdrawal: "0.1 stETH",
|
|
224
|
+
maxWithdrawal: "1000 stETH per request",
|
|
225
|
+
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."
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const account = rt.getAddress();
|
|
229
|
+
const tx = await rt.sdk.withdraw.request.requestWithdrawal({
|
|
230
|
+
amount,
|
|
231
|
+
token: "stETH",
|
|
232
|
+
account,
|
|
233
|
+
callback: () => {
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
simulated: false,
|
|
238
|
+
mode: rt.config.mode,
|
|
239
|
+
network: rt.chainAddresses.name,
|
|
240
|
+
action: "request_withdrawal",
|
|
241
|
+
amountSteth,
|
|
242
|
+
txHash: tx.hash,
|
|
243
|
+
requestIds: tx.result?.requests?.map((r) => r.requestId.toString()),
|
|
244
|
+
note: "Check status with get_withdrawal_status. Claim when finalized."
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function claimWithdrawals(requestIds, dryRun) {
|
|
248
|
+
const rt = getRuntime();
|
|
249
|
+
const ids = requestIds.map(BigInt);
|
|
250
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
251
|
+
if (shouldDryRun) {
|
|
252
|
+
return {
|
|
253
|
+
simulated: true,
|
|
254
|
+
mode: rt.config.mode,
|
|
255
|
+
network: rt.chainAddresses.name,
|
|
256
|
+
action: "claim_withdrawals",
|
|
257
|
+
requestIds,
|
|
258
|
+
note: "Claims can only be made after requests are finalized. Use get_withdrawal_status first."
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const account = rt.getAddress();
|
|
262
|
+
const tx = await rt.sdk.withdraw.claim.claimRequests({
|
|
263
|
+
requestsIds: ids,
|
|
264
|
+
account,
|
|
265
|
+
callback: () => {
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
simulated: false,
|
|
270
|
+
mode: rt.config.mode,
|
|
271
|
+
network: rt.chainAddresses.name,
|
|
272
|
+
action: "claim_withdrawals",
|
|
273
|
+
requestIds,
|
|
274
|
+
txHash: tx.hash
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async function getWithdrawalRequests(address) {
|
|
278
|
+
const rt = getRuntime();
|
|
279
|
+
const addr = address ?? rt.getAddress();
|
|
280
|
+
const requests = await rt.sdk.withdraw.views.getWithdrawalRequestsIds({ account: addr });
|
|
281
|
+
return {
|
|
282
|
+
address: addr,
|
|
283
|
+
mode: rt.config.mode,
|
|
284
|
+
network: rt.chainAddresses.name,
|
|
285
|
+
requests: requests.map((id) => id.toString()),
|
|
286
|
+
count: requests.length
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function getWithdrawalStatus(requestIds) {
|
|
290
|
+
const rt = getRuntime();
|
|
291
|
+
const ids = requestIds.map(BigInt);
|
|
292
|
+
const statuses = await rt.sdk.withdraw.views.getWithdrawalStatus({ requestsIds: ids });
|
|
293
|
+
return {
|
|
294
|
+
mode: rt.config.mode,
|
|
295
|
+
network: rt.chainAddresses.name,
|
|
296
|
+
statuses: statuses.map((s, i) => ({
|
|
297
|
+
requestId: requestIds[i],
|
|
298
|
+
amountOfStETH: formatEther3(s.amountOfStETH),
|
|
299
|
+
amountOfShares: formatEther3(s.amountOfShares),
|
|
300
|
+
owner: s.owner,
|
|
301
|
+
isFinalized: s.isFinalized,
|
|
302
|
+
isClaimed: s.isClaimed
|
|
303
|
+
}))
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/tools/wrap.ts
|
|
308
|
+
import { parseEther as parseEther3, formatEther as formatEther4 } from "viem";
|
|
309
|
+
async function wrapSteth(amountSteth, dryRun) {
|
|
310
|
+
const rt = getRuntime();
|
|
311
|
+
const amount = parseEther3(amountSteth);
|
|
312
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
313
|
+
const expectedWstETH = await rt.sdk.wrap.convertStethToWsteth(amount);
|
|
314
|
+
if (shouldDryRun) {
|
|
315
|
+
return {
|
|
316
|
+
simulated: true,
|
|
317
|
+
mode: rt.config.mode,
|
|
318
|
+
network: rt.chainAddresses.name,
|
|
319
|
+
action: "wrap_steth",
|
|
320
|
+
amountSteth,
|
|
321
|
+
expectedWstETH: formatEther4(expectedWstETH),
|
|
322
|
+
note: "wstETH is non-rebasing \u2014 balance stays fixed while value grows. Better for DeFi."
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const account = rt.getAddress();
|
|
326
|
+
const tx = await rt.sdk.wrap.wrapSteth({
|
|
327
|
+
value: amount,
|
|
328
|
+
account,
|
|
329
|
+
callback: () => {
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
simulated: false,
|
|
334
|
+
mode: rt.config.mode,
|
|
335
|
+
network: rt.chainAddresses.name,
|
|
336
|
+
action: "wrap_steth",
|
|
337
|
+
amountSteth,
|
|
338
|
+
txHash: tx.hash,
|
|
339
|
+
wstethReceived: formatEther4(tx.result?.wstethReceived ?? 0n)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async function unwrapWsteth(amountWsteth, dryRun) {
|
|
343
|
+
const rt = getRuntime();
|
|
344
|
+
const amount = parseEther3(amountWsteth);
|
|
345
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
346
|
+
const expectedStETH = await rt.sdk.wrap.convertWstethToSteth(amount);
|
|
347
|
+
if (shouldDryRun) {
|
|
348
|
+
return {
|
|
349
|
+
simulated: true,
|
|
350
|
+
mode: rt.config.mode,
|
|
351
|
+
network: rt.chainAddresses.name,
|
|
352
|
+
action: "unwrap_wsteth",
|
|
353
|
+
amountWsteth,
|
|
354
|
+
expectedStETH: formatEther4(expectedStETH),
|
|
355
|
+
note: "Unwrapping gives rebasing stETH back. Balance updates daily with rewards."
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const account = rt.getAddress();
|
|
359
|
+
const tx = await rt.sdk.wrap.unwrap({
|
|
360
|
+
value: amount,
|
|
361
|
+
account,
|
|
362
|
+
callback: () => {
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
simulated: false,
|
|
367
|
+
mode: rt.config.mode,
|
|
368
|
+
network: rt.chainAddresses.name,
|
|
369
|
+
action: "unwrap_wsteth",
|
|
370
|
+
amountWsteth,
|
|
371
|
+
txHash: tx.hash,
|
|
372
|
+
stethReceived: formatEther4(tx.result?.stethReceived ?? 0n)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
async function getConversionRate() {
|
|
376
|
+
const rt = getRuntime();
|
|
377
|
+
const oneEther = parseEther3("1");
|
|
378
|
+
const [wstethPerSteth, stethPerWsteth] = await Promise.all([
|
|
379
|
+
rt.sdk.wrap.convertStethToWsteth(oneEther),
|
|
380
|
+
rt.sdk.wrap.convertWstethToSteth(oneEther)
|
|
381
|
+
]);
|
|
382
|
+
return {
|
|
383
|
+
mode: rt.config.mode,
|
|
384
|
+
network: rt.chainAddresses.name,
|
|
385
|
+
"1_stETH_in_wstETH": formatEther4(wstethPerSteth),
|
|
386
|
+
"1_wstETH_in_stETH": formatEther4(stethPerWsteth),
|
|
387
|
+
note: "wstETH/stETH ratio increases over time as staking rewards accumulate."
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/tools/governance.ts
|
|
392
|
+
import { createPublicClient as createPublicClient2, http as http2, parseAbi, formatEther as formatEther5 } from "viem";
|
|
393
|
+
import { mainnet as mainnet2 } from "viem/chains";
|
|
394
|
+
import { defineChain as defineChain2 } from "viem";
|
|
395
|
+
var hoodi2 = defineChain2({
|
|
396
|
+
id: 560048,
|
|
397
|
+
name: "Hoodi Testnet",
|
|
398
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
399
|
+
rpcUrls: { default: { http: ["https://hoodi.drpc.org"] } }
|
|
400
|
+
});
|
|
401
|
+
var VOTING_ABI = parseAbi([
|
|
402
|
+
"function vote(uint256 _voteId, bool _supports, bool _executesIfDecided) external",
|
|
403
|
+
"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)",
|
|
404
|
+
"function votesLength() external view returns (uint256)"
|
|
405
|
+
]);
|
|
406
|
+
async function getProposals(count = 5) {
|
|
407
|
+
const rt = getRuntime();
|
|
408
|
+
const votingAddress = rt.chainAddresses.voting;
|
|
409
|
+
const rpcUrl = rt.config.rpc ?? rt.chainAddresses.defaultRpc;
|
|
410
|
+
const viemChain = rt.config.network === "mainnet" ? mainnet2 : hoodi2;
|
|
411
|
+
const client = createPublicClient2({ chain: viemChain, transport: http2(rpcUrl) });
|
|
412
|
+
const totalVotes = await client.readContract({
|
|
413
|
+
address: votingAddress,
|
|
414
|
+
abi: VOTING_ABI,
|
|
415
|
+
functionName: "votesLength"
|
|
416
|
+
});
|
|
417
|
+
const latest = Number(totalVotes);
|
|
418
|
+
const from = Math.max(0, latest - count);
|
|
419
|
+
const ids = Array.from({ length: latest - from }, (_, i) => from + i);
|
|
420
|
+
const votes = await Promise.all(
|
|
421
|
+
ids.map(async (id) => {
|
|
422
|
+
const v = await client.readContract({
|
|
423
|
+
address: votingAddress,
|
|
424
|
+
abi: VOTING_ABI,
|
|
425
|
+
functionName: "getVote",
|
|
426
|
+
args: [BigInt(id)]
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
id,
|
|
430
|
+
open: v[0],
|
|
431
|
+
executed: v[1],
|
|
432
|
+
startDate: new Date(Number(v[2]) * 1e3).toISOString(),
|
|
433
|
+
yea: formatEther5(v[6]),
|
|
434
|
+
nay: formatEther5(v[7]),
|
|
435
|
+
votingPower: formatEther5(v[8])
|
|
436
|
+
};
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
return {
|
|
440
|
+
mode: rt.config.mode,
|
|
441
|
+
network: rt.chainAddresses.name,
|
|
442
|
+
votingContract: votingAddress,
|
|
443
|
+
totalProposals: latest,
|
|
444
|
+
proposals: votes.reverse()
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async function getProposal(proposalId) {
|
|
448
|
+
const rt = getRuntime();
|
|
449
|
+
const votingAddress = rt.chainAddresses.voting;
|
|
450
|
+
const rpcUrl = rt.config.rpc ?? rt.chainAddresses.defaultRpc;
|
|
451
|
+
const viemChain = rt.config.network === "mainnet" ? mainnet2 : hoodi2;
|
|
452
|
+
const client = createPublicClient2({ chain: viemChain, transport: http2(rpcUrl) });
|
|
453
|
+
const v = await client.readContract({
|
|
454
|
+
address: votingAddress,
|
|
455
|
+
abi: VOTING_ABI,
|
|
456
|
+
functionName: "getVote",
|
|
457
|
+
args: [BigInt(proposalId)]
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
mode: rt.config.mode,
|
|
461
|
+
network: rt.chainAddresses.name,
|
|
462
|
+
id: proposalId,
|
|
463
|
+
open: v[0],
|
|
464
|
+
executed: v[1],
|
|
465
|
+
startDate: new Date(Number(v[2]) * 1e3).toISOString(),
|
|
466
|
+
snapshotBlock: v[3].toString(),
|
|
467
|
+
supportRequired: `${(Number(v[4]) / 1e16).toFixed(1)}%`,
|
|
468
|
+
minAcceptQuorum: `${(Number(v[5]) / 1e16).toFixed(1)}%`,
|
|
469
|
+
yea: formatEther5(v[6]),
|
|
470
|
+
nay: formatEther5(v[7]),
|
|
471
|
+
votingPower: formatEther5(v[8])
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
async function castVote(proposalId, support, dryRun) {
|
|
475
|
+
const rt = getRuntime();
|
|
476
|
+
const votingAddress = rt.chainAddresses.voting;
|
|
477
|
+
const shouldDryRun = rt.config.mode === "simulation" ? dryRun !== false : !!dryRun;
|
|
478
|
+
if (shouldDryRun) {
|
|
479
|
+
const proposal = await getProposal(proposalId);
|
|
480
|
+
return {
|
|
481
|
+
simulated: true,
|
|
482
|
+
mode: rt.config.mode,
|
|
483
|
+
network: rt.chainAddresses.name,
|
|
484
|
+
action: "cast_vote",
|
|
485
|
+
proposalId,
|
|
486
|
+
vote: support ? "YEA" : "NAY",
|
|
487
|
+
proposal,
|
|
488
|
+
note: "You need LDO tokens to vote. Voting power is based on LDO balance at snapshot block."
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const wallet = rt.getWallet();
|
|
492
|
+
const account = rt.getAddress();
|
|
493
|
+
const viemChain = rt.config.network === "mainnet" ? mainnet2 : hoodi2;
|
|
494
|
+
const hash = await wallet.writeContract({
|
|
495
|
+
chain: viemChain,
|
|
496
|
+
address: votingAddress,
|
|
497
|
+
abi: VOTING_ABI,
|
|
498
|
+
functionName: "vote",
|
|
499
|
+
args: [BigInt(proposalId), support, false],
|
|
500
|
+
account
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
simulated: false,
|
|
504
|
+
mode: rt.config.mode,
|
|
505
|
+
network: rt.chainAddresses.name,
|
|
506
|
+
action: "cast_vote",
|
|
507
|
+
proposalId,
|
|
508
|
+
vote: support ? "YEA" : "NAY",
|
|
509
|
+
txHash: hash
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/tools/settings.ts
|
|
514
|
+
function getSettings() {
|
|
515
|
+
const cfg2 = loadConfig();
|
|
516
|
+
return {
|
|
517
|
+
...redactedConfig(cfg2),
|
|
518
|
+
note: "Use update_settings to change mode, network, or rpc. Private key and API keys can only be changed via: moly setup"
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function updateSettings(patch) {
|
|
522
|
+
if (Object.keys(patch).length === 0) {
|
|
523
|
+
return { error: "No settings provided to update." };
|
|
524
|
+
}
|
|
525
|
+
const updated = applySettingsUpdate(patch);
|
|
526
|
+
return {
|
|
527
|
+
updated: true,
|
|
528
|
+
changes: patch,
|
|
529
|
+
current: redactedConfig(updated),
|
|
530
|
+
note: "Settings saved and applied. SDK reinitialized with new config."
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/server/index.ts
|
|
535
|
+
var cfg = loadConfig();
|
|
536
|
+
var modeNote = cfg.mode === "simulation" ? "\u{1F7E1} SIMULATION \u2014 dry_run true by default, no real transactions" : "\u{1F534} LIVE \u2014 real transactions on " + (cfg.network === "mainnet" ? "Ethereum Mainnet" : "Hoodi Testnet");
|
|
537
|
+
var server = new McpServer({ name: "@moly/lido", version: "1.0.0" });
|
|
538
|
+
server.tool(
|
|
539
|
+
"get_balance",
|
|
540
|
+
`Get ETH, stETH, and wstETH balances for an address. ${modeNote}`,
|
|
541
|
+
{ address: z.string().optional().describe("Ethereum address (defaults to configured wallet)") },
|
|
542
|
+
async ({ address }) => ({
|
|
543
|
+
content: [{ type: "text", text: JSON.stringify(await getBalance(address), null, 2) }]
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
server.tool(
|
|
547
|
+
"get_rewards",
|
|
548
|
+
"Get staking reward history for an address over N days.",
|
|
549
|
+
{
|
|
550
|
+
address: z.string().optional().describe("Ethereum address (defaults to configured wallet)"),
|
|
551
|
+
days: z.number().int().min(1).max(365).optional().default(7).describe("Days to look back (1-365)")
|
|
552
|
+
},
|
|
553
|
+
async ({ address, days }) => ({
|
|
554
|
+
content: [{ type: "text", text: JSON.stringify(await getRewards(address, days), null, 2) }]
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
server.tool(
|
|
558
|
+
"get_conversion_rate",
|
|
559
|
+
"Get current stETH \u2194 wstETH conversion rates.",
|
|
560
|
+
{},
|
|
561
|
+
async () => ({
|
|
562
|
+
content: [{ type: "text", text: JSON.stringify(await getConversionRate(), null, 2) }]
|
|
563
|
+
})
|
|
564
|
+
);
|
|
565
|
+
server.tool(
|
|
566
|
+
"stake_eth",
|
|
567
|
+
`Stake ETH to receive stETH (liquid staking). ${modeNote}`,
|
|
568
|
+
{
|
|
569
|
+
amount_eth: z.string().describe('Amount of ETH to stake (e.g. "0.1")'),
|
|
570
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting. Always true in simulation mode unless explicitly false.")
|
|
571
|
+
},
|
|
572
|
+
async ({ amount_eth, dry_run }) => ({
|
|
573
|
+
content: [{ type: "text", text: JSON.stringify(await stakeEth(amount_eth, dry_run), null, 2) }]
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
server.tool(
|
|
577
|
+
"request_withdrawal",
|
|
578
|
+
`Request withdrawal of stETH back to ETH via the Lido queue. Min 0.1, max 1000 stETH per request. ${modeNote}`,
|
|
579
|
+
{
|
|
580
|
+
amount_steth: z.string().describe("Amount of stETH to withdraw"),
|
|
581
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting.")
|
|
582
|
+
},
|
|
583
|
+
async ({ amount_steth, dry_run }) => ({
|
|
584
|
+
content: [{ type: "text", text: JSON.stringify(await requestWithdrawal(amount_steth, dry_run), null, 2) }]
|
|
585
|
+
})
|
|
586
|
+
);
|
|
587
|
+
server.tool(
|
|
588
|
+
"claim_withdrawals",
|
|
589
|
+
`Claim finalized withdrawal requests and receive ETH. ${modeNote}`,
|
|
590
|
+
{
|
|
591
|
+
request_ids: z.array(z.string()).describe("Withdrawal request NFT IDs to claim"),
|
|
592
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting.")
|
|
593
|
+
},
|
|
594
|
+
async ({ request_ids, dry_run }) => ({
|
|
595
|
+
content: [{ type: "text", text: JSON.stringify(await claimWithdrawals(request_ids, dry_run), null, 2) }]
|
|
596
|
+
})
|
|
597
|
+
);
|
|
598
|
+
server.tool(
|
|
599
|
+
"get_withdrawal_requests",
|
|
600
|
+
"Get all pending withdrawal request IDs for an address.",
|
|
601
|
+
{ address: z.string().optional().describe("Ethereum address (defaults to configured wallet)") },
|
|
602
|
+
async ({ address }) => ({
|
|
603
|
+
content: [{ type: "text", text: JSON.stringify(await getWithdrawalRequests(address), null, 2) }]
|
|
604
|
+
})
|
|
605
|
+
);
|
|
606
|
+
server.tool(
|
|
607
|
+
"get_withdrawal_status",
|
|
608
|
+
"Check finalization status of withdrawal request IDs. Must be finalized before claiming.",
|
|
609
|
+
{ request_ids: z.array(z.string()).describe("Withdrawal request IDs to check") },
|
|
610
|
+
async ({ request_ids }) => ({
|
|
611
|
+
content: [{ type: "text", text: JSON.stringify(await getWithdrawalStatus(request_ids), null, 2) }]
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
server.tool(
|
|
615
|
+
"wrap_steth",
|
|
616
|
+
`Wrap stETH into wstETH (non-rebasing, better for DeFi). ${modeNote}`,
|
|
617
|
+
{
|
|
618
|
+
amount_steth: z.string().describe("Amount of stETH to wrap"),
|
|
619
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting.")
|
|
620
|
+
},
|
|
621
|
+
async ({ amount_steth, dry_run }) => ({
|
|
622
|
+
content: [{ type: "text", text: JSON.stringify(await wrapSteth(amount_steth, dry_run), null, 2) }]
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
server.tool(
|
|
626
|
+
"unwrap_wsteth",
|
|
627
|
+
`Unwrap wstETH back to rebasing stETH. ${modeNote}`,
|
|
628
|
+
{
|
|
629
|
+
amount_wsteth: z.string().describe("Amount of wstETH to unwrap"),
|
|
630
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting.")
|
|
631
|
+
},
|
|
632
|
+
async ({ amount_wsteth, dry_run }) => ({
|
|
633
|
+
content: [{ type: "text", text: JSON.stringify(await unwrapWsteth(amount_wsteth, dry_run), null, 2) }]
|
|
634
|
+
})
|
|
635
|
+
);
|
|
636
|
+
server.tool(
|
|
637
|
+
"get_proposals",
|
|
638
|
+
"List recent Lido DAO governance proposals from the Aragon Voting contract.",
|
|
639
|
+
{ count: z.number().int().min(1).max(20).optional().default(5).describe("Number of recent proposals to fetch") },
|
|
640
|
+
async ({ count }) => ({
|
|
641
|
+
content: [{ type: "text", text: JSON.stringify(await getProposals(count), null, 2) }]
|
|
642
|
+
})
|
|
643
|
+
);
|
|
644
|
+
server.tool(
|
|
645
|
+
"get_proposal",
|
|
646
|
+
"Get detailed info on a specific Lido DAO governance proposal.",
|
|
647
|
+
{ proposal_id: z.number().int().describe("Proposal/vote ID") },
|
|
648
|
+
async ({ proposal_id }) => ({
|
|
649
|
+
content: [{ type: "text", text: JSON.stringify(await getProposal(proposal_id), null, 2) }]
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
server.tool(
|
|
653
|
+
"cast_vote",
|
|
654
|
+
`Vote YEA or NAY on a Lido DAO governance proposal. Requires LDO tokens at snapshot block. ${modeNote}`,
|
|
655
|
+
{
|
|
656
|
+
proposal_id: z.number().int().describe("Proposal/vote ID"),
|
|
657
|
+
support: z.boolean().describe("true = YEA (support), false = NAY (against)"),
|
|
658
|
+
dry_run: z.boolean().optional().describe("Simulate without broadcasting.")
|
|
659
|
+
},
|
|
660
|
+
async ({ proposal_id, support, dry_run }) => ({
|
|
661
|
+
content: [{ type: "text", text: JSON.stringify(await castVote(proposal_id, support, dry_run), null, 2) }]
|
|
662
|
+
})
|
|
663
|
+
);
|
|
664
|
+
server.tool(
|
|
665
|
+
"get_settings",
|
|
666
|
+
"Get current Moly configuration \u2014 mode, network, RPC, and AI provider. Private key and API keys are never exposed.",
|
|
667
|
+
{},
|
|
668
|
+
async () => ({
|
|
669
|
+
content: [{ type: "text", text: JSON.stringify(getSettings(), null, 2) }]
|
|
670
|
+
})
|
|
671
|
+
);
|
|
672
|
+
server.tool(
|
|
673
|
+
"update_settings",
|
|
674
|
+
"Change mode, network, RPC, or AI model mid-conversation. Changes persist to ~/.moly/config.json. Private key and API keys cannot be changed here \u2014 use: moly setup",
|
|
675
|
+
{
|
|
676
|
+
network: z.enum(["hoodi", "mainnet"]).optional().describe("Switch network"),
|
|
677
|
+
mode: z.enum(["simulation", "live"]).optional().describe("Switch between simulation (dry-run) and live mode"),
|
|
678
|
+
rpc: z.string().nullable().optional().describe("Set custom RPC URL, or null to use default public RPC"),
|
|
679
|
+
model: z.string().optional().describe("Change the AI model (if AI provider is configured)")
|
|
680
|
+
},
|
|
681
|
+
async ({ network, mode, rpc, model }) => ({
|
|
682
|
+
content: [{ type: "text", text: JSON.stringify(updateSettings({ network, mode, rpc, model }), null, 2) }]
|
|
683
|
+
})
|
|
684
|
+
);
|
|
685
|
+
var transport = new StdioServerTransport();
|
|
686
|
+
await server.connect(transport);
|
|
687
|
+
process.stderr.write(`@moly/lido MCP server started \u2014 ${modeNote}
|
|
688
|
+
`);
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moly-mcp/lido",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lido MCP Server — stake, unstake, wrap, govern. Works with Claude Desktop, Cursor, Windsurf, and any MCP client.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"moly": "./dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/server/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"dev": "tsup --watch",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clack/prompts": "^0.9.1",
|
|
24
|
+
"@lidofinance/lido-ethereum-sdk": "^4.7.0",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
26
|
+
"viem": "^2.47.4",
|
|
27
|
+
"zod": "^3.24.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20",
|
|
31
|
+
"tsup": "^8.4.0",
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
}
|
|
34
|
+
}
|