eth-mcp 0.2.0__py3-none-any.whl

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.
ethereum_mcp/server.py ADDED
@@ -0,0 +1,555 @@
1
+ """MCP server for Ethereum specs search."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from fastmcp import FastMCP
7
+ from pydantic import ValidationError
8
+
9
+ from .clients import (
10
+ get_client,
11
+ get_client_diversity,
12
+ get_recommended_pairs,
13
+ list_all_clients,
14
+ list_consensus_clients,
15
+ list_execution_clients,
16
+ )
17
+ from .expert.guidance import get_expert_guidance, list_guidance_topics
18
+ from .indexer.compiler import get_function_source
19
+ from .indexer.embedder import EmbeddingSearcher
20
+ from .logging import get_logger
21
+ from .models import (
22
+ ClientListInput,
23
+ ClientLookupInput,
24
+ ConstantLookupInput,
25
+ EipSearchInput,
26
+ FunctionAnalysisInput,
27
+ GuidanceInput,
28
+ SearchInput,
29
+ )
30
+
31
+ logger = get_logger("server")
32
+
33
+ # Initialize MCP server
34
+ mcp = FastMCP("ethereum-mcp")
35
+
36
+ # Default paths
37
+ DEFAULT_DATA_DIR = Path.home() / ".ethereum-mcp"
38
+ DEFAULT_DB_PATH = DEFAULT_DATA_DIR / "lancedb"
39
+ DEFAULT_SPECS_DIR = DEFAULT_DATA_DIR / "consensus-specs"
40
+
41
+ # Fork information
42
+ FORKS = {
43
+ "phase0": {"epoch": 0, "date": "2020-12-01", "description": "Beacon chain genesis"},
44
+ "altair": {"epoch": 74240, "date": "2021-10-27", "description": "Light clients, sync cttes"},
45
+ "bellatrix": {"epoch": 144896, "date": "2022-09-06", "description": "Merge preparation"},
46
+ "capella": {"epoch": 194048, "date": "2023-04-12", "description": "Withdrawals enabled"},
47
+ "deneb": {"epoch": 269568, "date": "2024-03-13", "description": "Blobs (EIP-4844)"},
48
+ "electra": {"epoch": 364032, "date": "2025-05-07", "description": "MaxEB, consolidations"},
49
+ "fulu": {"epoch": 411392, "date": "2025-11-15", "description": "PeerDAS, verkle prep"},
50
+ }
51
+
52
+ CURRENT_FORK = "electra" # Pectra = Prague + Electra
53
+
54
+
55
+ def get_searcher() -> EmbeddingSearcher:
56
+ """Get or create embedding searcher."""
57
+ return EmbeddingSearcher(DEFAULT_DB_PATH)
58
+
59
+
60
+ def _safe_path(base: Path, *parts: str) -> Path:
61
+ """
62
+ Safely construct a path, preventing directory traversal.
63
+
64
+ Args:
65
+ base: Base directory that must contain the result
66
+ *parts: Path components to join
67
+
68
+ Returns:
69
+ Resolved path guaranteed to be under base
70
+
71
+ Raises:
72
+ ValueError: If the resulting path would escape base directory
73
+ """
74
+ result = (base / "/".join(parts)).resolve()
75
+ base_resolved = base.resolve()
76
+ if not str(result).startswith(str(base_resolved)):
77
+ raise ValueError(f"Path traversal attempt: {parts}")
78
+ return result
79
+
80
+
81
+ @mcp.tool()
82
+ def eth_health() -> dict:
83
+ """
84
+ Check health of the Ethereum MCP server.
85
+
86
+ Verifies that the index exists and is queryable.
87
+ Call this to diagnose issues with search functionality.
88
+
89
+ Returns:
90
+ Health status with component checks
91
+ """
92
+ status = {
93
+ "healthy": True,
94
+ "checks": {},
95
+ }
96
+
97
+ # Check index exists
98
+ if DEFAULT_DB_PATH.exists():
99
+ status["checks"]["index_exists"] = "ok"
100
+ else:
101
+ status["checks"]["index_exists"] = "missing"
102
+ status["healthy"] = False
103
+
104
+ # Check specs directory
105
+ if DEFAULT_SPECS_DIR.exists():
106
+ status["checks"]["specs_dir"] = "ok"
107
+ else:
108
+ status["checks"]["specs_dir"] = "missing"
109
+
110
+ # Check compiled specs
111
+ compiled_dir = DEFAULT_DATA_DIR / "compiled"
112
+ if compiled_dir.exists() and any(compiled_dir.glob("*.json")):
113
+ status["checks"]["compiled_specs"] = "ok"
114
+ else:
115
+ status["checks"]["compiled_specs"] = "missing"
116
+
117
+ # Try a test query if index exists
118
+ if status["checks"]["index_exists"] == "ok":
119
+ try:
120
+ searcher = get_searcher()
121
+ searcher.search("test", limit=1) # Just verify search works
122
+ status["checks"]["search"] = "ok"
123
+ except Exception as e:
124
+ status["checks"]["search"] = f"error: {e}"
125
+ status["healthy"] = False
126
+
127
+ # Add paths for debugging
128
+ status["paths"] = {
129
+ "data_dir": str(DEFAULT_DATA_DIR),
130
+ "db_path": str(DEFAULT_DB_PATH),
131
+ "specs_dir": str(DEFAULT_SPECS_DIR),
132
+ }
133
+
134
+ return status
135
+
136
+
137
+ @mcp.tool()
138
+ def eth_get_current_fork() -> dict:
139
+ """
140
+ Get the current active Ethereum fork.
141
+
142
+ Returns:
143
+ Current fork name and metadata
144
+ """
145
+ return {
146
+ "fork": CURRENT_FORK,
147
+ "info": FORKS[CURRENT_FORK],
148
+ "note": "Pectra = Prague (EL) + Electra (CL)",
149
+ }
150
+
151
+
152
+ @mcp.tool()
153
+ def eth_list_forks() -> list[dict]:
154
+ """
155
+ List all major Ethereum network upgrades with dates and epochs.
156
+
157
+ Returns:
158
+ List of forks in chronological order
159
+ """
160
+ return [
161
+ {"fork": name, **info, "current": name == CURRENT_FORK}
162
+ for name, info in FORKS.items()
163
+ ]
164
+
165
+
166
+ @mcp.tool()
167
+ def eth_search(query: str, fork: str | None = None, limit: int = 5) -> list[dict]:
168
+ """
169
+ Search Ethereum specs and EIPs together.
170
+
171
+ This is the primary search tool - it searches across all indexed
172
+ content including consensus specs and EIPs.
173
+
174
+ Args:
175
+ query: Search query (e.g., "slashing penalty calculation")
176
+ fork: Optional fork to filter by (defaults to current fork context)
177
+ limit: Maximum results to return
178
+
179
+ Returns:
180
+ List of relevant chunks with source and similarity score
181
+ """
182
+ try:
183
+ validated = SearchInput(query=query, fork=fork, limit=limit)
184
+ except ValidationError as e:
185
+ return [{"error": str(e)}]
186
+
187
+ searcher = get_searcher()
188
+
189
+ # Default to current fork context if not specified
190
+ fork = validated.fork or CURRENT_FORK
191
+
192
+ results = searcher.search(validated.query, limit=validated.limit, fork=fork)
193
+
194
+ # Also search without fork filter to catch EIPs
195
+ eip_results = searcher.search_eip(query)
196
+
197
+ # Merge and dedupe
198
+ seen = set()
199
+ merged = []
200
+ for r in results + eip_results:
201
+ key = (r["source"], r["section"])
202
+ if key not in seen:
203
+ seen.add(key)
204
+ merged.append(r)
205
+
206
+ return sorted(merged, key=lambda x: x["score"], reverse=True)[:limit]
207
+
208
+
209
+ @mcp.tool()
210
+ def eth_search_specs(query: str, fork: str | None = None, limit: int = 5) -> list[dict]:
211
+ """
212
+ Search consensus specs only (no EIPs).
213
+
214
+ Use this when you specifically want spec content without EIP results.
215
+
216
+ Args:
217
+ query: Search query
218
+ fork: Optional fork filter
219
+ limit: Maximum results
220
+
221
+ Returns:
222
+ List of spec chunks
223
+ """
224
+ try:
225
+ validated = SearchInput(query=query, fork=fork, limit=limit)
226
+ except ValidationError as e:
227
+ return [{"error": str(e)}]
228
+
229
+ searcher = get_searcher()
230
+ fork = validated.fork or CURRENT_FORK
231
+
232
+ # Exclude EIP chunk type
233
+ results = searcher.search(validated.query, limit=validated.limit * 2, fork=fork)
234
+ return [r for r in results if r["chunk_type"] != "eip"][:validated.limit]
235
+
236
+
237
+ @mcp.tool()
238
+ def eth_grep_constant(constant_name: str, fork: str | None = None) -> dict | None:
239
+ """
240
+ Look up a specific constant value from the specs.
241
+
242
+ Optimized for finding constants like MAX_EFFECTIVE_BALANCE,
243
+ MIN_SLASHING_PENALTY_QUOTIENT, etc.
244
+
245
+ Args:
246
+ constant_name: Name of constant (e.g., "MAX_EFFECTIVE_BALANCE")
247
+ fork: Fork to look up (defaults to current)
248
+
249
+ Returns:
250
+ Constant definition with value and context
251
+ """
252
+ try:
253
+ validated = ConstantLookupInput(constant_name=constant_name, fork=fork)
254
+ except ValidationError as e:
255
+ return {"error": str(e)}
256
+
257
+ fork = validated.fork or CURRENT_FORK
258
+
259
+ # Try compiled specs first - use safe path construction
260
+ try:
261
+ spec_file = _safe_path(DEFAULT_DATA_DIR, "compiled", f"{fork}_spec.json")
262
+ except ValueError:
263
+ return {"error": "Invalid fork name"}
264
+
265
+ if spec_file.exists():
266
+ with open(spec_file) as f:
267
+ spec_data = json.load(f)
268
+ if validated.constant_name in spec_data.get("constants", {}):
269
+ return {
270
+ "constant": validated.constant_name,
271
+ "value": spec_data["constants"][validated.constant_name],
272
+ "fork": fork,
273
+ "source": "compiled_spec",
274
+ }
275
+
276
+ # Fall back to embedding search
277
+ searcher = get_searcher()
278
+ results = searcher.search_constant(validated.constant_name)
279
+
280
+ for r in results:
281
+ if validated.constant_name.upper() in r["content"].upper():
282
+ return {
283
+ "constant": validated.constant_name,
284
+ "context": r["content"],
285
+ "fork": r["fork"] or fork,
286
+ "source": r["source"],
287
+ }
288
+
289
+ return None
290
+
291
+
292
+ @mcp.tool()
293
+ def eth_analyze_function(function_name: str, fork: str | None = None) -> dict | None:
294
+ """
295
+ Get the actual Python implementation of a spec function.
296
+
297
+ Returns the complete function source code from the consensus specs.
298
+
299
+ Args:
300
+ function_name: Name of function (e.g., "process_slashings")
301
+ fork: Fork version (defaults to current)
302
+
303
+ Returns:
304
+ Function source code and metadata
305
+ """
306
+ try:
307
+ validated = FunctionAnalysisInput(function_name=function_name, fork=fork)
308
+ except ValidationError as e:
309
+ return {"error": str(e)}
310
+
311
+ fork = validated.fork or CURRENT_FORK
312
+
313
+ # Try to get from source files
314
+ source = get_function_source(DEFAULT_SPECS_DIR, fork, validated.function_name)
315
+
316
+ if source:
317
+ return {
318
+ "function": validated.function_name,
319
+ "fork": fork,
320
+ "source": source,
321
+ }
322
+
323
+ # Fall back to embedding search
324
+ searcher = get_searcher()
325
+ results = searcher.search_function(validated.function_name, fork=fork)
326
+
327
+ if results:
328
+ return {
329
+ "function": validated.function_name,
330
+ "fork": results[0]["fork"] or fork,
331
+ "source": results[0]["content"],
332
+ "file": results[0]["source"],
333
+ }
334
+
335
+ return None
336
+
337
+
338
+ @mcp.tool()
339
+ def eth_get_spec_version() -> dict:
340
+ """
341
+ Get metadata about indexed spec versions.
342
+
343
+ Returns:
344
+ Version info for indexed specs and EIPs
345
+ """
346
+ import subprocess
347
+
348
+ version_info = {
349
+ "indexed_forks": list(FORKS.keys()),
350
+ "current_fork": CURRENT_FORK,
351
+ }
352
+
353
+ # Try to get git commit info
354
+ if DEFAULT_SPECS_DIR.exists():
355
+ try:
356
+ result = subprocess.run(
357
+ ["git", "rev-parse", "--short", "HEAD"],
358
+ cwd=DEFAULT_SPECS_DIR,
359
+ capture_output=True,
360
+ text=True,
361
+ )
362
+ if result.returncode == 0:
363
+ version_info["consensus_specs_commit"] = result.stdout.strip()
364
+ else:
365
+ logger.debug("Git rev-parse failed: %s", result.stderr)
366
+ except Exception as e:
367
+ logger.debug("Failed to get git commit info: %s", e)
368
+
369
+ # Check if index exists
370
+ db_path = DEFAULT_DB_PATH
371
+ if db_path.exists():
372
+ version_info["index_path"] = str(db_path)
373
+ version_info["index_exists"] = True
374
+ else:
375
+ version_info["index_exists"] = False
376
+
377
+ return version_info
378
+
379
+
380
+ @mcp.tool()
381
+ def eth_expert_guidance(topic: str) -> dict | None:
382
+ """
383
+ Get curated expert interpretations on Ethereum topics.
384
+
385
+ This provides nuanced guidance that goes beyond what's explicitly
386
+ in the specs - including common gotchas, implementation notes,
387
+ and insights from EF discussions.
388
+
389
+ Args:
390
+ topic: Topic to get guidance on (e.g., "slashing", "churn", "withdrawals")
391
+
392
+ Returns:
393
+ Expert guidance with key points and references
394
+ """
395
+ try:
396
+ validated = GuidanceInput(topic=topic)
397
+ except ValidationError as e:
398
+ return {"error": str(e)}
399
+
400
+ guidance = get_expert_guidance(validated.topic)
401
+
402
+ if guidance:
403
+ return {
404
+ "topic": validated.topic,
405
+ "guidance": guidance,
406
+ }
407
+
408
+ # List available topics if not found
409
+ available = list_guidance_topics()
410
+ return {
411
+ "topic": validated.topic,
412
+ "error": f"No guidance found for '{validated.topic}'",
413
+ "available_topics": available,
414
+ }
415
+
416
+
417
+ @mcp.tool()
418
+ def eth_search_eip(query: str, eip_number: str | None = None, limit: int = 5) -> list[dict]:
419
+ """
420
+ Search EIPs specifically.
421
+
422
+ Args:
423
+ query: Search query
424
+ eip_number: Optional specific EIP number to search within
425
+ limit: Maximum results
426
+
427
+ Returns:
428
+ List of relevant EIP chunks
429
+ """
430
+ try:
431
+ validated = EipSearchInput(query=query, eip_number=eip_number, limit=limit)
432
+ except ValidationError as e:
433
+ return [{"error": str(e)}]
434
+
435
+ searcher = get_searcher()
436
+ results = searcher.search_eip(validated.query)
437
+
438
+ if validated.eip_number:
439
+ results = [r for r in results if r.get("eip") == validated.eip_number]
440
+
441
+ return results[:validated.limit]
442
+
443
+
444
+ @mcp.tool()
445
+ def eth_list_clients(layer: str | None = None) -> list[dict]:
446
+ """
447
+ List Ethereum client implementations.
448
+
449
+ Ethereum has separate execution layer (EL) and consensus layer (CL) clients
450
+ that run together. Client diversity is critical for network security.
451
+
452
+ Args:
453
+ layer: Filter by "execution" or "consensus" (default: both)
454
+
455
+ Returns:
456
+ List of clients with their details
457
+ """
458
+ try:
459
+ validated = ClientListInput(layer=layer)
460
+ except ValidationError as e:
461
+ return [{"error": str(e)}]
462
+
463
+ if validated.layer == "execution":
464
+ clients = list_execution_clients()
465
+ elif validated.layer == "consensus":
466
+ clients = list_consensus_clients()
467
+ else:
468
+ clients = list_all_clients()
469
+
470
+ return [
471
+ {
472
+ "name": c.name,
473
+ "layer": c.layer,
474
+ "organization": c.organization,
475
+ "language": c.language,
476
+ "repo": c.repo,
477
+ "status": c.mainnet_status,
478
+ "percentage": c.node_percentage or c.stake_percentage,
479
+ }
480
+ for c in clients
481
+ ]
482
+
483
+
484
+ @mcp.tool()
485
+ def eth_get_client(name: str) -> dict | None:
486
+ """
487
+ Get details about a specific Ethereum client.
488
+
489
+ Args:
490
+ name: Client name (e.g., "geth", "reth", "lighthouse", "prysm")
491
+
492
+ Returns:
493
+ Full client details including features and notes
494
+ """
495
+ try:
496
+ validated = ClientLookupInput(name=name)
497
+ except ValidationError as e:
498
+ return {"error": str(e)}
499
+
500
+ client = get_client(validated.name)
501
+ if not client:
502
+ all_clients = list_all_clients()
503
+ return {
504
+ "error": f"Client '{validated.name}' not found",
505
+ "available": [c.name for c in all_clients],
506
+ }
507
+
508
+ return {
509
+ "name": client.name,
510
+ "layer": client.layer,
511
+ "organization": client.organization,
512
+ "language": client.language,
513
+ "repo": client.repo,
514
+ "description": client.description,
515
+ "status": client.mainnet_status,
516
+ "percentage": client.node_percentage or client.stake_percentage,
517
+ "key_features": client.key_features,
518
+ "notes": client.notes,
519
+ }
520
+
521
+
522
+ @mcp.tool()
523
+ def eth_get_client_diversity() -> dict:
524
+ """
525
+ Get Ethereum client diversity statistics.
526
+
527
+ Client diversity is critical for network security. If any client has >66% share,
528
+ a bug in that client could cause incorrect finalization.
529
+
530
+ Returns:
531
+ Diversity statistics for EL and CL, health assessment, and recommendations
532
+ """
533
+ return get_client_diversity()
534
+
535
+
536
+ @mcp.tool()
537
+ def eth_get_recommended_client_pairs() -> list[dict]:
538
+ """
539
+ Get recommended execution + consensus client pairings.
540
+
541
+ Validators need both an EL and CL client. Some combinations work better together.
542
+
543
+ Returns:
544
+ List of recommended EL+CL pairs with notes
545
+ """
546
+ return get_recommended_pairs()
547
+
548
+
549
+ def run():
550
+ """Run the MCP server."""
551
+ mcp.run()
552
+
553
+
554
+ if __name__ == "__main__":
555
+ run()
@@ -0,0 +1 @@
1
+ """MCP tools for Ethereum specs search."""