- 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>
295 lines
10 KiB
Python
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)
|