""" Local MCP Client - Pull open-source MCP servers from GitHub, run locally, route through caching shield. Architecture: GitHub MCP repos → local clone → stdio transport → cache wrapper → our tooling One layer. No Smithery. No cloud gateways. Just git + stdio + cache. Usage: from app.caching_shield.local_mcp import LocalMCPClient client = LocalMCPClient() # Pull and run an MCP server from GitHub await client.install("boar-network/blockchain-mcp") result = await client.call("boar", "eth_getBalance", {"address": "0x..."}) """ import asyncio import hashlib import json import logging import subprocess import time from pathlib import Path logger = logging.getLogger("local_mcp") MCP_HOME = Path("/root/.hermes/mcp-servers") class LocalMCPClient: """Pulls MCP servers from GitHub, runs them locally via stdio, caches results.""" def __init__(self): MCP_HOME.mkdir(parents=True, exist_ok=True) self._servers: dict[str, dict] = {} # name -> {repo, process, tools} self._l1: dict[str, tuple] = {} async def install(self, repo: str, name: str | None = None) -> dict: """Clone an MCP server from GitHub and discover its tools.""" name = name or repo.split("/")[-1] server_dir = MCP_HOME / name # Clone if not exists if not server_dir.exists(): r = subprocess.run( ["git", "clone", f"https://github.com/{repo}.git", str(server_dir)], capture_output=True, text=True, timeout=30, ) if r.returncode != 0: raise RuntimeError(f"Clone failed: {r.stderr[:200]}") # Detect package manager and install deps if (server_dir / "package.json").exists(): subprocess.run(["npm", "install", "--production"], cwd=server_dir, capture_output=True, timeout=60) elif (server_dir / "requirements.txt").exists(): subprocess.run( ["pip", "install", "-r", "requirements.txt"], cwd=server_dir, capture_output=True, timeout=60, ) # Discover tools via MCP stdio handshake tools = await self._discover_tools(name, server_dir) self._servers[name] = {"repo": repo, "dir": str(server_dir), "tools": tools} logger.info(f"MCP {name}: {len(tools)} tools from {repo}") return {"name": name, "tools": len(tools), "repo": repo} async def _discover_tools(self, name: str, server_dir: Path) -> list[dict]: """Run MCP server and call tools/list to discover available tools.""" entry = self._find_entrypoint(server_dir) if not entry: return [] try: # Start MCP server as subprocess proc = await asyncio.create_subprocess_exec( *entry["command"], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=server_dir, ) # MCP initialize init_msg = ( json.dumps( { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "rmi-caching-shield", "version": "1.0"}, }, } ) + "\n" ) proc.stdin.write(init_msg.encode()) await proc.stdin.drain() # Read response line = await asyncio.wait_for(proc.stdout.readline(), timeout=10) json.loads(line.decode()) # Send initialized notification proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + b"\n") await proc.stdin.drain() # List tools proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + b"\n") await proc.stdin.drain() line = await asyncio.wait_for(proc.stdout.readline(), timeout=10) tools_resp = json.loads(line.decode()) tools = tools_resp.get("result", {}).get("tools", []) # Keep process alive for future calls self._servers[name] = self._servers.get(name, {}) self._servers[name]["process"] = proc proc.stdin.close() return tools except Exception as e: logger.warning(f"MCP discover {name} failed: {e}") return [] def _find_entrypoint(self, server_dir: Path) -> dict | None: """Find how to start the MCP server.""" # Check package.json for bin/main pkg = server_dir / "package.json" if pkg.exists(): try: data = json.loads(pkg.read_text()) if data.get("main"): return {"command": ["node", data["main"]]} if data.get("bin"): bin_val = data["bin"] if isinstance(bin_val, str): return {"command": ["node", bin_val]} if isinstance(bin_val, dict): return {"command": ["node", next(iter(bin_val.values()))]} except Exception: pass # Check for Python entrypoint for entry in ["server.py", "mcp_server.py", "main.py", "__main__.py"]: if (server_dir / entry).exists(): return {"command": ["python3", entry]} # Check for TypeScript entry for entry in ["src/index.ts", "src/server.ts", "index.ts"]: if (server_dir / entry).exists(): return {"command": ["npx", "tsx", entry]} return None async def call(self, server: str, tool: str, args: dict, ttl: int = 60) -> dict | None: """Call an MCP tool through caching shield.""" cache_key = hashlib.sha256(f"mcp:{server}:{tool}:{json.dumps(args, sort_keys=True)}".encode()).hexdigest()[:24] # L1 cache entry = self._l1.get(cache_key) if entry: expiry, data = entry if time.monotonic() < expiry: return data del self._l1[cache_key] svr = self._servers.get(server, {}) proc = svr.get("process") if not proc: return None try: req = ( json.dumps( { "jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": tool, "arguments": args}, } ) + "\n" ) proc.stdin.write(req.encode()) await proc.stdin.drain() line = await asyncio.wait_for(proc.stdout.readline(), timeout=30) resp = json.loads(line.decode()) result = resp.get("result", {}).get("content", [{}])[0].get("text", "") data = json.loads(result) if result else None if data: self._l1[cache_key] = (time.monotonic() + ttl, data) return data except Exception as e: logger.debug(f"MCP call {server}/{tool}: {e}") return None def list_servers(self) -> list[dict]: return [{"name": n, "repo": s["repo"]} for n, s in self._servers.items()] def stats(self) -> dict: return {"servers": len(self._servers), "cache_entries": len(self._l1)} _mcp_client: LocalMCPClient | None = None def get_local_mcp() -> LocalMCPClient: global _mcp_client if _mcp_client is None: _mcp_client = LocalMCPClient() return _mcp_client # Curated list of free, open-source MCPs to clone and expose. # These were vetted for: non-overlap with RMI native, MIT/Apache/AGPL license, # active maintenance, real-world value to crypto intelligence users. EXPANDED_MCP_REPOS = { "fear-greed-mcp": { "repo": "kukapay/crypto-feargreed-mcp", "service": "fear-greed-mcp", "category": "sentiment", "license": "MIT", "description": "Crypto Fear & Greed Index via Alternative.me", }, "crypto-indicators-mcp": { "repo": "kukapay/crypto-indicators-mcp", "service": "crypto-indicators-mcp", "category": "analysis", "license": "MIT", "description": "50+ technical analysis indicators", }, "openzeppelin-wizard": { "repo": "OpenZeppelin/contracts-wizard", "service": "openzeppelin-wizard", "category": "security", "license": "AGPL-3.0", "description": "OpenZeppelin audited smart contract generator", }, "web3-research-mcp": { "repo": "aaronjmars/web3-research-mcp", "service": "web3-research-mcp", "category": "research", "license": "MIT", "description": "Local web research: CoinGecko + DefiLlama + URL fetcher", }, } async def install_curated_mcps() -> dict: """Install all curated free MCPs from GitHub and discover their tools. Returns summary: {server_name: {repo, tools_count, status, error}} """ client = get_local_mcp() results = {} for name, meta in EXPANDED_MCP_REPOS.items(): try: info = await client.install(meta["repo"], name=name) results[name] = { "repo": meta["repo"], "service": meta["service"], "license": meta["license"], "tools_count": info.get("tools", 0), "status": "ok", } except Exception as e: results[name] = { "repo": meta["repo"], "service": meta["service"], "license": meta["license"], "tools_count": 0, "status": "error", "error": str(e)[:200], } return results async def call_expanded_tool(service: str, tool_name: str, args: dict | None = None, ttl: int = 60) -> dict | None: """Call a tool from a curated MCP service.""" args = args or {} client = get_local_mcp() return await client.call(service, tool_name, args, ttl=ttl)