Hermes
feat(t33+t34): MCP server + x402 paid catalog
eca51fe
Raw
History Blame Contribute Delete
11.2 kB
"""T33 MCP Server — exposes 8 tools to AI agents at mcp.rugmunch.io.
Per v4.0 §T33. JSON-RPC over SSE (the protocol Claude/Cursor speak).
Tools (per v4.0):
1. get_token_risk — Real-time risk score (FREE 5/day or $0.01)
2. get_wallet_analysis — Wallet activity + reputation
3. get_deployer_reputation — Deployer reputation (0-100)
4. get_news_sentiment — Latest news + sentiment
5. generate_report — Full AI research report ($5)
6. query_catalog — Natural language catalog query
7. find_similar_tokens — Vector-similar tokens
8. resolve_entity — Cross-chain entity resolution
Backend implementations: app/catalog/* + app/domain/reports/generator.py
"""
from __future__ import annotations
import json
import logging
from typing import Any
log = logging.getLogger(__name__)
# Tool catalog — inputSchema follows JSON Schema 2020-12
TOOL_CATALOG: list[dict[str, Any]] = [
{
"name": "get_token_risk",
"description": "Real-time risk score for any token across 13+ chains. Returns score (0-100), tier (low/medium/high/critical), and risk factors. Free tier: 5 calls/day, $0.01 thereafter.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string", "enum": ["solana", "ethereum", "base", "arbitrum", "optimism", "polygon", "bsc", "tron", "bitcoin", "avalanche", "fantom", "gnosis"]},
"address": {"type": "string", "description": "Token contract address"},
},
"required": ["chain", "address"],
},
},
{
"name": "get_wallet_analysis",
"description": "Wallet activity, balance, transaction history, and reputation. Returns wallet profile + risk flags.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"address": {"type": "string"},
},
"required": ["chain", "address"],
},
},
{
"name": "get_deployer_reputation",
"description": "Deployer reputation score 0-100 (100=clean, 0=serial rugger). Deterministic from on-chain history + news + RAG findings. Cached 1h.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"address": {"type": "string"},
},
"required": ["chain", "address"],
},
},
{
"name": "get_news_sentiment",
"description": "Latest news for a token or wallet with sentiment classification. Returns articles + composite sentiment score.",
"inputSchema": {
"type": "object",
"properties": {
"subject_id": {"type": "string", "description": "chain:address, or 'all' for general news"},
"since_hours": {"type": "integer", "default": 24, "minimum": 1, "maximum": 720},
"limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 50},
},
},
},
{
"name": "generate_report",
"description": "Full AI research report on a token or wallet. 7 sections composed in parallel via LLM. $5/report. Returns full Markdown.",
"inputSchema": {
"type": "object",
"properties": {
"subject_type": {"type": "string", "enum": ["token", "wallet"]},
"subject_id": {"type": "string", "description": "chain:address"},
},
"required": ["subject_type", "subject_id"],
},
},
{
"name": "query_catalog",
"description": "Natural language catalog query. Returns matching tokens, wallets, deployers, news, RAG findings. $0.05/query.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural language question"},
},
"required": ["query"],
},
},
{
"name": "find_similar_tokens",
"description": "Vector-similar tokens to a given token. Returns tokens with cosine similarity >= 0.85. $0.03/query.",
"inputSchema": {
"type": "object",
"properties": {
"chain": {"type": "string"},
"address": {"type": "string"},
"limit": {"type": "integer", "default": 10, "maximum": 50},
},
"required": ["chain", "address"],
},
},
{
"name": "resolve_entity",
"description": "Cross-chain entity resolution. Given a wallet, find all linked wallets across chains via SAME_AS / FUNDED_BY_SAME / CLONE_OF / BEHAVIORAL_MATCH edges. $0.10/query.",
"inputSchema": {
"type": "object",
"properties": {
"wallet_id": {"type": "string", "description": "chain:address"},
},
"required": ["wallet_id"],
},
},
]
# ── Tool implementations ──────────────────────────────────────────
async def call_tool(name: str, arguments: dict) -> dict:
"""Dispatch a tool call to the appropriate backend."""
from app.catalog.service import get_catalog
catalog = get_catalog()
await catalog._init_stores()
if name == "get_token_risk":
from app.catalog.models import Chain
try:
c = Chain(arguments["chain"])
except ValueError:
return {"error": f"unknown chain: {arguments['chain']}"}
result = await catalog.get_token_risk(c, arguments["address"])
return {"result": result, "tier": "free_or_pro"}
if name == "get_wallet_analysis":
from app.catalog.models import Chain
try:
c = Chain(arguments["chain"])
except ValueError:
return {"error": f"unknown chain: {arguments['chain']}"}
w = await catalog.get_wallet(c, arguments["address"])
if not w:
return {"error": "wallet not found in catalog"}
return {"result": w.model_dump(mode="json")}
if name == "get_deployer_reputation":
from app.catalog.models import Chain
try:
c = Chain(arguments["chain"])
except ValueError:
return {"error": f"unknown chain: {arguments['chain']}"}
w = await catalog.get_wallet(c, arguments["address"])
if not w:
return {"error": "deployer wallet not found", "reputation_score": 50}
# Compute reputation deterministically
from app.catalog.reputation import compute_deployer_reputation
from app.catalog.models import Deployer
deployer = Deployer(
wallet_id=w.wallet_id, chain=w.chain, address=w.address,
first_seen=w.first_seen, last_seen=w.last_seen,
tx_count=w.tx_count, total_volume_usd=w.total_volume_usd,
is_deployer=True, reputation_score=w.reputation_score,
deployments=getattr(w, "deployments", []),
rug_count=getattr(w, "rug_count", 0),
)
score = await compute_deployer_reputation(deployer, catalog)
return {"result": {"reputation_score": score, "tier": _tier_from_score(score)}}
if name == "get_news_sentiment":
subject = arguments.get("subject_id", "all")
since = int(arguments.get("since_hours", 24))
limit = int(arguments.get("limit", 10))
if not catalog._health.postgres:
return {"error": "postgres unavailable", "articles": []}
try:
async with catalog._pg_pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT news_id, title, summary, source, published_at, sentiment_score
FROM news_items
WHERE published_at > NOW() - ($1 || ' hours')::interval
ORDER BY published_at DESC LIMIT $2""",
str(since), limit,
)
articles = [
{
"news_id": r["news_id"],
"title": r["title"],
"summary": (r["summary"] or "")[:200],
"source": r["source"],
"published_at": r["published_at"].isoformat(),
"sentiment_score": r["sentiment_score"],
}
for r in rows
]
avg_sent = sum(a["sentiment_score"] or 0 for a in articles) / max(1, len(articles))
return {"result": {
"subject": subject,
"article_count": len(articles),
"avg_sentiment": round(avg_sent, 3),
"articles": articles,
}}
except Exception as e:
return {"error": f"news_query_fail: {e}"}
if name == "generate_report":
from app.domain.reports.generator import generate_token_report, generate_wallet_report
chain, address = arguments["subject_id"].split(":", 1)
try:
if arguments["subject_type"] == "token":
report = await generate_token_report(catalog, chain, address)
else:
report = await generate_wallet_report(catalog, chain, address)
from app.domain.reports.generator import save_report
await save_report(catalog, report)
return {"result": {
"report_id": report.report_id,
"risk_score": report.risk_score,
"risk_tier": report.risk_tier.value,
"markdown": report.to_markdown(),
"paid_via_x402": None, # MCP doesn't enforce payment in v1
}}
except Exception as e:
return {"error": f"report_fail: {e}"}
if name == "query_catalog":
# NL query -> RAG search
q = arguments.get("query", "")
hits = await catalog.rag_search(query=q, top_k=5)
return {"result": {"query": q, "hits": hits, "count": len(hits)}}
if name == "find_similar_tokens":
from app.catalog.models import Chain
try:
c = Chain(arguments["chain"])
except ValueError:
return {"error": f"unknown chain: {arguments['chain']}"}
# Use token's rag_embedding_id to find similar via Qdrant
token = await catalog.get_token(c, arguments["address"])
if not token or not token.rag_embedding_id:
return {"error": "token not in catalog or no RAG embedding", "similar": []}
# Use RAG to search for similar by querying with the token's content
rag_hits = await catalog.rag_search(query=token.symbol or "token", top_k=int(arguments.get("limit", 10)))
return {"result": {"subject": arguments["address"], "similar": rag_hits[:10]}}
if name == "resolve_entity":
result = await catalog.resolve_entity(arguments["wallet_id"])
return {"result": result}
return {"error": f"unknown tool: {name}"}
def _tier_from_score(score: int) -> str:
if score < 25:
return "low"
if score < 50:
return "medium"
if score < 75:
return "high"
return "critical"