rmi-backend/app/caching_shield/local_mcp.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

295 lines
10 KiB
Python

"""
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)