- 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>
574 lines
22 KiB
Python
Executable file
574 lines
22 KiB
Python
Executable file
"""Dynamic MCP Tool Catalog - reads from gateway configs + external MCP servers
|
|
Expanded to include 200+ tools across 40+ services
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from fastapi import APIRouter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Provider name sanitization map
|
|
_PROVIDER_MAP = {
|
|
"DexScreener": "DEX",
|
|
"CoinGecko": "Market Data",
|
|
"DefiLlama": "DeFi Analytics",
|
|
"Birdeye": "Token Discovery",
|
|
"PumpFun": "Launch",
|
|
"Pump.fun": "Launch",
|
|
"GeckoTerminal": "DEX Pool",
|
|
"DexPaprika": "Multi-Source DEX",
|
|
}
|
|
|
|
_SERVICE_MAP = {
|
|
"dexscreener": "dex-analytics",
|
|
"coingecko": "market-data",
|
|
"defillama": "defi-analytics",
|
|
"birdeye": "token-discovery",
|
|
"pumpfun": "launch-platform",
|
|
"geckoterminal": "dex-pool-data",
|
|
"dexpaprika": "multi-source-dex",
|
|
"dexscreener_new": "dex-analytics",
|
|
"dexscreener_hot": "dex-analytics",
|
|
"dexscreener_meme": "dex-analytics",
|
|
"defillama_stablecoins": "defi-analytics",
|
|
"defillama_bridges": "defi-analytics",
|
|
"pumpfun_gainers": "launch-platform",
|
|
}
|
|
|
|
|
|
def _sanitize_tool(tool: dict) -> dict:
|
|
"""Remove upstream provider names from user-visible tool fields"""
|
|
for field in ("name", "description"):
|
|
if field in tool and isinstance(tool[field], str):
|
|
val = tool[field]
|
|
for old, new in _PROVIDER_MAP.items():
|
|
val = val.replace(old, new)
|
|
tool[field] = val
|
|
if "service" in tool and isinstance(tool["service"], str):
|
|
tool["service"] = _SERVICE_MAP.get(tool["service"], tool["service"])
|
|
return tool
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/x402", tags=["x402-catalog"])
|
|
|
|
# Cache for tool catalog
|
|
_catalog_cache: dict = {}
|
|
|
|
|
|
def parse_gateway_tools(gateway_dir: str) -> list[dict]:
|
|
"""Parse RMI_TOOLS definitions from a gateway index.ts file.
|
|
|
|
Handles nested braces by tracking brace depth."""
|
|
index_path = os.path.join(gateway_dir, "index.ts")
|
|
if not os.path.exists(index_path):
|
|
return []
|
|
|
|
with open(index_path) as f:
|
|
content = f.read()
|
|
|
|
tools = []
|
|
|
|
# Find the RMI_TOOLS block
|
|
rmi_start = content.find("const RMI_TOOLS")
|
|
if rmi_start == -1:
|
|
return []
|
|
|
|
# Find opening brace of RMI_TOOLS
|
|
brace_start = content.find("{", rmi_start)
|
|
if brace_start == -1:
|
|
return []
|
|
|
|
# Find matching closing brace by tracking depth
|
|
depth = 0
|
|
pos = brace_start
|
|
while pos < len(content):
|
|
if content[pos] == "{":
|
|
depth += 1
|
|
elif content[pos] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
pos += 1
|
|
|
|
rmi_block = content[brace_start : pos + 1]
|
|
|
|
# Now parse individual tool definitions within the block
|
|
# Tools look like: tool_name: { name: "...", description: "...", ... }
|
|
# We need to handle nested braces in extras
|
|
|
|
# Split into tool entries by finding top-level 'word:' patterns
|
|
tool_pattern = re.compile(r"(\w+):\s*\{", re.MULTILINE)
|
|
|
|
for tm in tool_pattern.finditer(rmi_block):
|
|
tool_id = tm.group(1)
|
|
|
|
# Skip non-tool entries
|
|
if tool_id in ("interface", "type", "export", "import", "const", "let", "var"):
|
|
continue
|
|
if tool_id.startswith("//") or tool_id.startswith("RMI_TOOLS"):
|
|
continue
|
|
|
|
# Find the matching closing brace for this tool
|
|
tool_start = tm.end() - 1 # position of opening {
|
|
depth = 0
|
|
tool_end = tool_start
|
|
for i in range(tool_start, len(rmi_block)):
|
|
if rmi_block[i] == "{":
|
|
depth += 1
|
|
elif rmi_block[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
tool_end = i + 1
|
|
break
|
|
|
|
block = rmi_block[tool_start:tool_end]
|
|
|
|
# Extract fields
|
|
name = re.search(r'name:\s*"([^"]*)"', block)
|
|
desc = re.search(r'description:\s*"([^"]*)"', block)
|
|
price = re.search(r'price:\s*"\$?([^"]*)"', block)
|
|
atomic = re.search(r'priceAtomic:\s*"([^"]*)"', block)
|
|
category = re.search(r'category:\s*"([^"]*)"', block)
|
|
trial = re.search(r"trialFree:\s*(\d+)", block)
|
|
method = re.search(r'method:\s*"([^"]*)"', block)
|
|
|
|
if name and category:
|
|
tools.append(
|
|
{
|
|
"id": tool_id,
|
|
"name": name.group(1),
|
|
"description": desc.group(1) if desc else "",
|
|
"price": f"${price.group(1)}" if price else "$0",
|
|
"priceUsd": float(price.group(1)) if price else 0,
|
|
"priceAtomic": atomic.group(1) if atomic else "0",
|
|
"category": category.group(1).lower(),
|
|
"trialFree": int(trial.group(1)) if trial else 0,
|
|
"method": method.group(1) if method else "POST",
|
|
"service": "rmi-native",
|
|
"source": "native",
|
|
}
|
|
)
|
|
|
|
return tools
|
|
|
|
|
|
def load_external_mcp_tools() -> list[dict]:
|
|
"""Load expanded external MCP tool definitions.
|
|
|
|
Priority:
|
|
1. Local expanded_mcp_catalog.py file (fast, offline)
|
|
2. mcp-router.rugmunch.io/tools (live, always current)
|
|
3. Returns empty list if both unavailable
|
|
"""
|
|
base_dir = os.path.dirname(os.path.dirname(__file__))
|
|
catalog_path = os.path.join(base_dir, "services", "expanded_mcp_catalog.py")
|
|
|
|
# Try local file first
|
|
if os.path.exists(catalog_path):
|
|
try:
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("expanded_mcp_catalog", catalog_path)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError("Cannot load spec for expanded_mcp_catalog")
|
|
module = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
|
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
tools = getattr(module, "EXTERNAL_MCP_TOOLS", [])
|
|
if tools:
|
|
logger.info(f"Loaded {len(tools)} tools from expanded_mcp_catalog.py")
|
|
return tools
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load expanded_mcp_catalog.py: {e}")
|
|
|
|
# Fallback: fetch from mcp-router dynamically
|
|
try:
|
|
import json as _json
|
|
import urllib.request
|
|
|
|
url = "https://mcp.rugmunch.io/tools"
|
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
data = _json.loads(resp.read())
|
|
|
|
tools = []
|
|
# mcp-router returns service groups, each with tools
|
|
if isinstance(data, dict):
|
|
services = data.get("services", data.get("tools", data))
|
|
if isinstance(services, list):
|
|
for service in services:
|
|
service_name = service.get("name", service.get("service", "unknown"))
|
|
service_tools = service.get("tools", [])
|
|
if isinstance(service_tools, list):
|
|
for t in service_tools:
|
|
tool_id = t.get("name", t.get("id", ""))
|
|
if tool_id:
|
|
tools.append(
|
|
{
|
|
"id": tool_id,
|
|
"name": t.get("description", tool_id),
|
|
"description": t.get("description", f"MCP tool: {tool_id}"),
|
|
"price": "$0.01",
|
|
"priceUsd": 0.01,
|
|
"category": "mcp-external",
|
|
"trialFree": 3,
|
|
"method": "MCP",
|
|
"service": service_name,
|
|
"source": "mcp-router",
|
|
"chains": ["SOLANA", "BASE"],
|
|
}
|
|
)
|
|
else:
|
|
# Flat tool list
|
|
for key, val in service.items():
|
|
if isinstance(val, dict) and "name" in val:
|
|
tools.append(
|
|
{
|
|
"id": key,
|
|
"name": val.get("name", key),
|
|
"description": val.get("description", ""),
|
|
"price": "$0.01",
|
|
"priceUsd": 0.01,
|
|
"category": "mcp-external",
|
|
"trialFree": 3,
|
|
"method": "MCP",
|
|
"service": service_name,
|
|
"source": "mcp-router",
|
|
"chains": ["SOLANA", "BASE"],
|
|
}
|
|
)
|
|
|
|
if tools:
|
|
logger.info(f"Loaded {len(tools)} tools from mcp-router dynamically")
|
|
return tools
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch from mcp-router: {e}")
|
|
|
|
return []
|
|
|
|
|
|
def discover_route_tools() -> list[dict]:
|
|
"""Discover tools from FastAPI route definitions"""
|
|
tools = []
|
|
backend_dir = os.path.dirname(__file__)
|
|
skip = {
|
|
"bundles",
|
|
"discovery",
|
|
"frameworks",
|
|
"comprehensive_audit",
|
|
"anthropic-tools",
|
|
"gemini-tools",
|
|
"langchain-tools",
|
|
"openai-tools",
|
|
"bundles/all_in_one",
|
|
"bundles/intelligence_pack",
|
|
"bundles/security_pack",
|
|
"bundles/forensic_pack",
|
|
"{tool_id}",
|
|
"payment-methods",
|
|
}
|
|
|
|
# Load authoritative pricing/categories from TOOL_PRICES
|
|
try:
|
|
from app.routers.x402_enforcement import TOOL_PRICES
|
|
except Exception:
|
|
TOOL_PRICES = {}
|
|
|
|
for fname in ["x402_tools.py", "x402_forensic_tools.py"]:
|
|
fpath = os.path.join(backend_dir, fname)
|
|
if not os.path.exists(fpath):
|
|
continue
|
|
with open(fpath) as f:
|
|
content = f.read()
|
|
# Find all route paths
|
|
routes = re.findall(r'@router\.(?:get|post)\("\/([^"]+)"\)', content)
|
|
for route in routes:
|
|
if route in skip:
|
|
continue
|
|
# Use TOOL_PRICES as source of truth for pricing and category
|
|
if route in TOOL_PRICES:
|
|
tp = TOOL_PRICES[route]
|
|
tools.append(
|
|
{
|
|
"id": route,
|
|
"name": tp.get("description", route.replace("_", " ").title()),
|
|
"description": tp.get("description", f"Tool: {route}"),
|
|
"price": f"${tp.get('price_usd', 0.01):.2f}",
|
|
"priceUsd": tp.get("price_usd", 0.01),
|
|
"category": tp.get("category", "analysis"),
|
|
"trialFree": tp.get("trial_free", 1),
|
|
"method": "POST",
|
|
"service": "rmi-native",
|
|
"source": "route",
|
|
"chains": [],
|
|
}
|
|
)
|
|
continue
|
|
# Try to find docstring for description
|
|
desc = f"Tool: {route}"
|
|
doc_match = re.search(rf'@router\.(?:get|post)\("/{route}"\)[\s\S]*?"""([^"]*)"""', content)
|
|
if doc_match:
|
|
desc = doc_match.group(1).strip().split("\n")[0].strip()
|
|
tools.append(
|
|
{
|
|
"id": route,
|
|
"name": route.replace("_", " ").title(),
|
|
"description": desc,
|
|
"price": "$0.01",
|
|
"priceUsd": 0.01,
|
|
"category": "api",
|
|
"trialFree": 1,
|
|
"method": "POST",
|
|
"service": "rmi-native",
|
|
"source": "route",
|
|
"chains": [],
|
|
}
|
|
)
|
|
return tools
|
|
|
|
|
|
def get_catalog():
|
|
"""Build full tool catalog from TOOL_PRICES + X402_TOOL_PRICING (databus) = single source of truth.
|
|
|
|
Combined enforcement + databus-only tools = 170 total.
|
|
Gateway configs and route discovery are used for enrichment (chains, icons, etc).
|
|
"""
|
|
|
|
# Always rebuild (no stale caching)
|
|
base_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
|
|
# ── Primary source: TOOL_PRICES + DATABUS_TOOLS (170 total, single source of truth) ──
|
|
try:
|
|
from app.routers.x402_enforcement import CHAIN_USDC, TOOL_PRICES # noqa: F401
|
|
|
|
all_tools = {}
|
|
services = set()
|
|
categories = set()
|
|
|
|
for tool_id, pricing in TOOL_PRICES.items():
|
|
desc = pricing.get("description", f"{tool_id} - crypto intelligence tool")
|
|
category = pricing.get("category", "analysis")
|
|
chains = []
|
|
# If it's a per-chain variant, show the specific chain
|
|
base_tool = pricing.get("base_tool")
|
|
chain = pricing.get("chain")
|
|
if chain:
|
|
chains = [chain.upper()]
|
|
|
|
all_tools[tool_id] = {
|
|
"id": tool_id,
|
|
"name": desc,
|
|
"description": desc,
|
|
"price": f"${pricing.get('price_usd', 0.01):.2f}",
|
|
"priceUsd": pricing.get("price_usd", 0.01),
|
|
"priceAtomic": pricing.get("price_atoms", "10000"),
|
|
"category": category,
|
|
"trialFree": pricing.get("trial_free", 1),
|
|
"method": "POST",
|
|
"service": "rmi-native",
|
|
"source": "enforcement",
|
|
"chains": chains,
|
|
"base_tool": base_tool,
|
|
"chain": chain,
|
|
}
|
|
services.add("rmi-native")
|
|
categories.add(category)
|
|
|
|
# Add databus-only tools (not in TOOL_PRICES enforcement)
|
|
try:
|
|
from app.routers.x402_databus_tools import X402_TOOL_PRICING as DATABUS_TOOLS
|
|
|
|
for tool_id, pricing in DATABUS_TOOLS.items():
|
|
if tool_id not in all_tools:
|
|
desc = pricing.get("description", f"{tool_id} - DataBus crypto intelligence")
|
|
category = pricing.get("category", "data")
|
|
all_tools[tool_id] = {
|
|
"id": tool_id,
|
|
"name": desc,
|
|
"description": desc,
|
|
"price": f"${pricing.get('price_usd', 0.05):.2f}",
|
|
"priceUsd": pricing.get("price_usd", 0.05),
|
|
"priceAtomic": pricing.get("price_atoms", "50000"),
|
|
"category": category,
|
|
"trialFree": pricing.get("trial_free", 1),
|
|
"method": "POST",
|
|
"service": "rmi-databus",
|
|
"source": "databus",
|
|
"chains": [],
|
|
"base_tool": None,
|
|
"chain": None,
|
|
}
|
|
services.add("rmi-databus")
|
|
categories.add(category)
|
|
except ImportError:
|
|
pass
|
|
except ImportError:
|
|
all_tools = {}
|
|
services = set()
|
|
categories = set()
|
|
|
|
# ── Enrich from gateway configs (add chain info, icons) ──
|
|
candidates = [
|
|
"/app/x402-gateway",
|
|
os.path.join(base_dir, "x402-gateway"),
|
|
os.path.join(os.path.dirname(base_dir), "x402-gateway"),
|
|
"/root/backend/x402-gateway",
|
|
]
|
|
gateway_base = None
|
|
for c in candidates:
|
|
if os.path.isdir(c):
|
|
gateway_base = c
|
|
break
|
|
|
|
chains = {}
|
|
if gateway_base and os.path.exists(gateway_base):
|
|
for chain_dir in sorted(os.listdir(gateway_base)):
|
|
chain_path = os.path.join(gateway_base, chain_dir)
|
|
if not os.path.isdir(chain_path):
|
|
continue
|
|
tools = parse_gateway_tools(chain_path)
|
|
if tools:
|
|
chains[chain_dir] = len(tools)
|
|
for t in tools:
|
|
tid = t["id"]
|
|
if tid in all_tools:
|
|
# Enrich existing entry with chain info
|
|
if chain_dir.upper() not in all_tools[tid].get("chains", []):
|
|
all_tools[tid]["chains"].append(chain_dir.upper())
|
|
else:
|
|
# New tool from gateway not in TOOL_PRICES - add it
|
|
t["chains"] = [chain_dir.upper()]
|
|
all_tools[tid] = t
|
|
services.add("rmi-native")
|
|
categories.add(t.get("category", "unknown"))
|
|
|
|
# ── Enrich from FastAPI route definitions ──
|
|
route_tools = discover_route_tools()
|
|
for t in route_tools:
|
|
tid = t["id"]
|
|
if tid in all_tools:
|
|
# Enrich - route definitions may have more accurate descriptions
|
|
if not all_tools[tid].get("description") or all_tools[tid].get("description", "").startswith("Tool:"):
|
|
all_tools[tid]["description"] = t.get("description", all_tools[tid].get("description", ""))
|
|
else:
|
|
all_tools[tid] = t
|
|
services.add("rmi-native")
|
|
categories.add(t.get("category", "analysis"))
|
|
|
|
# ── Enrich from external MCP servers ──
|
|
external_tools = load_external_mcp_tools()
|
|
for t in external_tools:
|
|
tid = t.get("id", "")
|
|
if not tid:
|
|
continue
|
|
# Normalize pricing - external tools may have price_usd (snake) or priceUsd (camel)
|
|
price_usd = t.get("price_usd", t.get("priceUsd", 0.01))
|
|
price_atoms = t.get("price_atoms", t.get("priceAtomic", str(int(price_usd * 1_000_000))))
|
|
trial_free = t.get("trial_free", t.get("trialFree", 1))
|
|
|
|
if tid in all_tools:
|
|
# Enrich existing entry with external pricing if missing
|
|
if not all_tools[tid].get("priceUsd"):
|
|
all_tools[tid].update(
|
|
{
|
|
"price": f"${price_usd:.2f}",
|
|
"priceUsd": price_usd,
|
|
"priceAtomic": price_atoms,
|
|
"trialFree": trial_free,
|
|
"method": t.get("method", "POST"),
|
|
}
|
|
)
|
|
else:
|
|
# New tool from external MCP
|
|
t["chains"] = [c.upper() for c in t.get("chains", [])]
|
|
t["price"] = f"${price_usd:.2f}"
|
|
t["priceUsd"] = price_usd
|
|
t["priceAtomic"] = price_atoms
|
|
t["trialFree"] = trial_free
|
|
t["method"] = t.get("method", "MCP")
|
|
t["source"] = t.get("source", "expanded-mcp")
|
|
all_tools[tid] = t
|
|
services.add(t.get("service", "unknown"))
|
|
categories.add(t.get("category", "unknown"))
|
|
|
|
# Base tools that aren't variants should show all chains they support
|
|
chain_suffixes = [
|
|
"_solana",
|
|
"_base",
|
|
"_ethereum",
|
|
"_bsc",
|
|
"_arbitrum",
|
|
"_polygon",
|
|
"_avalanche",
|
|
"_fantom",
|
|
"_gnosis",
|
|
"_optimism",
|
|
]
|
|
for tid, tool in all_tools.items():
|
|
# If a base tool has variants, show chains on the base
|
|
base = tid
|
|
for suffix in chain_suffixes:
|
|
if tid.endswith(suffix):
|
|
base = tid[: -len(suffix)]
|
|
break
|
|
# Check if this base has chain variants
|
|
chain_variants = [t for t in all_tools if t.startswith(base + "_")]
|
|
if chain_variants and not tool.get("chain"):
|
|
# This is a base tool - it supports all chains its variants cover
|
|
variant_chains = []
|
|
for v in chain_variants:
|
|
v_chain = all_tools[v].get("chain", "")
|
|
if v_chain:
|
|
variant_chains.append(v_chain.upper())
|
|
if variant_chains:
|
|
tool["chains"] = sorted(set(variant_chains + tool.get("chains", [])))
|
|
|
|
# Sanitize ALL tools before returning
|
|
tool_list = sorted([_sanitize_tool(dict(t)) for t in all_tools.values()], key=lambda x: x.get("priceUsd", 0))
|
|
|
|
result = {
|
|
"chains": chains or {"base": 0, "solana": 0},
|
|
"total_tools": len(tool_list),
|
|
"total_chains": len(chains) or 2,
|
|
"total_services": len(services),
|
|
"tools": tool_list,
|
|
"categories": sorted(categories),
|
|
"services": sorted(services),
|
|
}
|
|
|
|
_catalog_cache.clear()
|
|
_catalog_cache.update(result)
|
|
return result
|
|
|
|
|
|
@router.get("/tools-catalog")
|
|
async def list_tools_catalog():
|
|
"""Get all available MCP tools across all chains and external services"""
|
|
return get_catalog()
|
|
|
|
|
|
@router.get("/tools-catalog/{chain}")
|
|
async def list_chain_tools(chain: str):
|
|
"""Get tools for a specific chain (includes native + external)"""
|
|
catalog = get_catalog()
|
|
chain_upper = chain.upper()
|
|
chain_tools = [t for t in catalog["tools"] if chain_upper in t.get("chains", [])]
|
|
return {"chain": chain, "count": len(chain_tools), "tools": chain_tools}
|
|
|
|
|
|
@router.get("/tools-catalog/category/{category}")
|
|
async def list_category_tools(category: str):
|
|
"""Get tools in a specific category across all chains"""
|
|
catalog = get_catalog()
|
|
filtered = [t for t in catalog["tools"] if t.get("category", "") == category.lower()]
|
|
return {"category": category, "count": len(filtered), "tools": filtered}
|
|
|
|
|
|
@router.get("/tools-catalog/service/{service}")
|
|
async def list_service_tools(service: str):
|
|
"""Get tools from a specific external MCP service"""
|
|
catalog = get_catalog()
|
|
filtered = [t for t in catalog["tools"] if t.get("service", "") == service.lower()]
|
|
return {"service": service, "count": len(filtered), "tools": filtered}
|