Hermes commited on
Commit ·
eca51fe
1
Parent(s): 24dd239
feat(t33+t34): MCP server + x402 paid catalog
Browse files- backend/app/api/v1/__init__.py +2 -3
- backend/app/api/v1/mcp/__init__.py +3 -3
- backend/app/api/v1/mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/app/api/v1/mcp/__pycache__/router.cpython-311.pyc +0 -0
- backend/app/api/v1/mcp/router.py +120 -0
- backend/app/domain/x402/__init__.py +26 -27
- backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/app/domain/x402/__pycache__/middleware.cpython-311.pyc +0 -0
- backend/app/domain/x402/__pycache__/router.cpython-311.pyc +0 -0
- backend/app/domain/x402/__pycache__/service.cpython-311.pyc +0 -0
- backend/app/domain/x402/middleware.py +263 -0
- backend/app/domain/x402/router.py +155 -0
- backend/app/mcp/__init__.py +10 -0
- backend/app/mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/app/mcp/__pycache__/server.cpython-311.pyc +0 -0
- backend/app/mcp/server.py +266 -0
- backend/app/mcp/x402_mcp_server.py +785 -0
- backend/main.py +3 -1
backend/app/api/v1/__init__.py
CHANGED
|
@@ -49,9 +49,8 @@ from app.api.v1.public.scanner import router as scanner_router # noqa: E402
|
|
| 49 |
|
| 50 |
api_v1_router.append(scanner_router)
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
api_v1_router.append(x402_payments_router)
|
| 55 |
|
| 56 |
from app.api.v1.rag.search import router as rag_v2_router # noqa: E402
|
| 57 |
|
|
|
|
| 49 |
|
| 50 |
api_v1_router.append(scanner_router)
|
| 51 |
|
| 52 |
+
# x402 moved to app.domain.x402 (T34 v2)
|
| 53 |
+
# Old app/api/v1/x402/payments.py removed to avoid model conflicts
|
|
|
|
| 54 |
|
| 55 |
from app.api.v1.rag.search import router as rag_v2_router # noqa: E402
|
| 56 |
|
backend/app/api/v1/mcp/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
"""
|
|
|
|
| 1 |
+
"""MCP v1 routes."""
|
| 2 |
+
from .router import router
|
| 3 |
|
| 4 |
+
__all__ = ["router"]
|
|
|
backend/app/api/v1/mcp/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (243 Bytes). View file
|
|
|
backend/app/api/v1/mcp/__pycache__/router.cpython-311.pyc
ADDED
|
Binary file (5.71 kB). View file
|
|
|
backend/app/api/v1/mcp/router.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T33 MCP Server — HTTP wrapper for SSE transport.
|
| 2 |
+
|
| 3 |
+
Per v4.0 §T33. Endpoints:
|
| 4 |
+
POST /mcp JSON-RPC 2.0 endpoint
|
| 5 |
+
GET /mcp/tools Tool catalog
|
| 6 |
+
POST /mcp/call/{tool_id} Direct tool execution (no JSON-RPC)
|
| 7 |
+
|
| 8 |
+
The server speaks the Model Context Protocol natively. Claude Desktop
|
| 9 |
+
and Cursor connect via:
|
| 10 |
+
{"mcpServers": {"rugmunch": {"url": "https://mcp.rugmunch.io/mcp", "transport": "sse"}}}
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 19 |
+
from pydantic import BaseModel
|
| 20 |
+
|
| 21 |
+
from app.mcp.server import TOOL_CATALOG, call_tool
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/mcp", tags=["mcp"])
|
| 24 |
+
log = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class JsonRpcRequest(BaseModel):
|
| 28 |
+
jsonrpc: str = "2.0"
|
| 29 |
+
method: str
|
| 30 |
+
params: dict[str, Any] = {}
|
| 31 |
+
id: int | str | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class JsonRpcResponse(BaseModel):
|
| 35 |
+
jsonrpc: str = "2.0"
|
| 36 |
+
result: Any | None = None
|
| 37 |
+
error: dict | None = None
|
| 38 |
+
id: int | str | None = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("")
|
| 42 |
+
async def jsonrpc_handler(req: JsonRpcRequest) -> dict:
|
| 43 |
+
"""JSON-RPC 2.0 endpoint for MCP clients.
|
| 44 |
+
|
| 45 |
+
Methods:
|
| 46 |
+
- initialize → returns server info
|
| 47 |
+
- tools/list → returns tool catalog
|
| 48 |
+
- tools/call → dispatches to backend
|
| 49 |
+
- resources/list → empty
|
| 50 |
+
- prompts/list → empty
|
| 51 |
+
"""
|
| 52 |
+
if req.jsonrpc != "2.0":
|
| 53 |
+
return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "invalid jsonrpc version"}, "id": req.id}
|
| 54 |
+
|
| 55 |
+
if req.method == "initialize":
|
| 56 |
+
return {
|
| 57 |
+
"jsonrpc": "2.0",
|
| 58 |
+
"result": {
|
| 59 |
+
"protocolVersion": "2024-11-05",
|
| 60 |
+
"serverInfo": {
|
| 61 |
+
"name": "rugmunch-intelligence",
|
| 62 |
+
"version": "4.0.0",
|
| 63 |
+
"description": "Crypto intelligence platform — 13+ chains, 8 MCP tools, x402 paid tier",
|
| 64 |
+
},
|
| 65 |
+
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
| 66 |
+
},
|
| 67 |
+
"id": req.id,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if req.method == "tools/list":
|
| 71 |
+
return {
|
| 72 |
+
"jsonrpc": "2.0",
|
| 73 |
+
"result": {"tools": TOOL_CATALOG},
|
| 74 |
+
"id": req.id,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if req.method == "tools/call":
|
| 78 |
+
name = req.params.get("name", "")
|
| 79 |
+
arguments = req.params.get("arguments", {})
|
| 80 |
+
if not name:
|
| 81 |
+
return {"jsonrpc": "2.0", "error": {"code": -32602, "message": "tool name required"}, "id": req.id}
|
| 82 |
+
result = await call_tool(name, arguments)
|
| 83 |
+
return {
|
| 84 |
+
"jsonrpc": "2.0",
|
| 85 |
+
"result": {
|
| 86 |
+
"content": [{"type": "text", "text": json.dumps(result, default=str)[:50000]}],
|
| 87 |
+
"isError": "error" in result,
|
| 88 |
+
},
|
| 89 |
+
"id": req.id,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if req.method == "resources/list":
|
| 93 |
+
return {"jsonrpc": "2.0", "result": {"resources": []}, "id": req.id}
|
| 94 |
+
|
| 95 |
+
if req.method == "prompts/list":
|
| 96 |
+
return {"jsonrpc": "2.0", "result": {"prompts": []}, "id": req.id}
|
| 97 |
+
|
| 98 |
+
if req.method == "notifications/initialized":
|
| 99 |
+
return {"jsonrpc": "2.0", "result": {}, "id": req.id}
|
| 100 |
+
|
| 101 |
+
return {
|
| 102 |
+
"jsonrpc": "2.0",
|
| 103 |
+
"error": {"code": -32601, "message": f"method not found: {req.method}"},
|
| 104 |
+
"id": req.id,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.get("/tools")
|
| 109 |
+
async def list_tools() -> dict:
|
| 110 |
+
"""Plain JSON endpoint (for direct integration, no JSON-RPC)."""
|
| 111 |
+
return {"server": "rugmunch-intelligence", "version": "4.0.0", "tools": TOOL_CATALOG}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.post("/call/{tool_id}")
|
| 115 |
+
async def direct_call(tool_id: str, request: Request) -> dict:
|
| 116 |
+
"""Direct tool execution (no JSON-RPC). For curl/scripts."""
|
| 117 |
+
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
| 118 |
+
arguments = body.get("arguments", body) if isinstance(body, dict) else {}
|
| 119 |
+
result = await call_tool(tool_id, arguments)
|
| 120 |
+
return result
|
backend/app/domain/x402/__init__.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
-
"""x402 domain — auto-registers its health check.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from app.core import health as health_mod
|
|
@@ -6,14 +12,13 @@ from app.core.health import DomainHealth
|
|
| 6 |
|
| 7 |
|
| 8 |
async def _health_check() -> DomainHealth:
|
| 9 |
-
"""x402 health: catalog
|
| 10 |
try:
|
| 11 |
-
from app.
|
| 12 |
-
from app.routers.x402_enforcement import router
|
| 13 |
return DomainHealth(
|
| 14 |
name="x402",
|
| 15 |
healthy=True,
|
| 16 |
-
details={"
|
| 17 |
)
|
| 18 |
except Exception as e:
|
| 19 |
return DomainHealth(name="x402", healthy=False, error=str(e))
|
|
@@ -22,25 +27,19 @@ async def _health_check() -> DomainHealth:
|
|
| 22 |
health_mod.register_health_check("x402", _health_check)
|
| 23 |
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"PaymentFacilitator",
|
| 42 |
-
"PaymentReceipt",
|
| 43 |
-
"PaymentRequest",
|
| 44 |
-
"X402Tier",
|
| 45 |
-
"X402Service",
|
| 46 |
-
]
|
|
|
|
| 1 |
+
"""x402 domain — auto-registers its health check + HTTP routes.
|
| 2 |
+
|
| 3 |
+
T34 from v4.0. Sovereign-first x402 payment layer for AI agents.
|
| 4 |
+
|
| 5 |
+
Re-exports the legacy models (PaymentFacilitator, X402Tier) for backward
|
| 6 |
+
compatibility with v1 routers that import from app.domain.x402.
|
| 7 |
+
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
from app.core import health as health_mod
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
async def _health_check() -> DomainHealth:
|
| 15 |
+
"""x402 health: catalog + middleware available."""
|
| 16 |
try:
|
| 17 |
+
from app.domain.x402.middleware import PRICING
|
|
|
|
| 18 |
return DomainHealth(
|
| 19 |
name="x402",
|
| 20 |
healthy=True,
|
| 21 |
+
details={"tools": len(PRICING), "middleware": "available"},
|
| 22 |
)
|
| 23 |
except Exception as e:
|
| 24 |
return DomainHealth(name="x402", healthy=False, error=str(e))
|
|
|
|
| 27 |
health_mod.register_health_check("x402", _health_check)
|
| 28 |
|
| 29 |
|
| 30 |
+
# Re-export legacy models for backward compat with v1 routers
|
| 31 |
+
try:
|
| 32 |
+
from app.domain.x402.models import ( # noqa: F401
|
| 33 |
+
PaymentFacilitator,
|
| 34 |
+
X402Tier,
|
| 35 |
+
PaidTool,
|
| 36 |
+
X402Receipt,
|
| 37 |
+
)
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# Re-export the new T34 router
|
| 43 |
+
from app.domain.x402.router import router # noqa: E402
|
| 44 |
+
|
| 45 |
+
__all__ = ["router", "PaymentFacilitator", "X402Tier", "PaidTool", "X402Receipt"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc
CHANGED
|
Binary files a/backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc and b/backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc differ
|
|
|
backend/app/domain/x402/__pycache__/middleware.cpython-311.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
backend/app/domain/x402/__pycache__/router.cpython-311.pyc
ADDED
|
Binary file (9.77 kB). View file
|
|
|
backend/app/domain/x402/__pycache__/service.cpython-311.pyc
DELETED
|
Binary file (8.16 kB)
|
|
|
backend/app/domain/x402/middleware.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T34 x402 Payment Middleware.
|
| 2 |
+
|
| 3 |
+
Per v4.0 §T34. HTTP 402 'Payment Required' repurposed for AI agents.
|
| 4 |
+
|
| 5 |
+
Flow:
|
| 6 |
+
1. Agent calls paid endpoint without X-Payment header
|
| 7 |
+
2. Server returns 402 with payment challenge (signed invoice)
|
| 8 |
+
3. Agent's wallet pays on-chain, gets tx_hash
|
| 9 |
+
4. Agent re-calls with X-Payment: <base64(tx_hash + signature)>
|
| 10 |
+
5. Server verifies on-chain payment via web3, fulfills request
|
| 11 |
+
|
| 12 |
+
Pricing (per v4.0):
|
| 13 |
+
Free: 5 calls/day
|
| 14 |
+
Pro: $0.01/call (1000 calls/day)
|
| 15 |
+
Ent: $0.001/call (unlimited)
|
| 16 |
+
|
| 17 |
+
For v1, we use a simplified flow:
|
| 18 |
+
- Track calls per agent in Redis (sliding window)
|
| 19 |
+
- Receipts logged to x402_receipts table
|
| 20 |
+
- Payment verification stubbed (returns True for now)
|
| 21 |
+
- The 402 challenge includes a payment_url the agent hits
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import base64
|
| 26 |
+
import hashlib
|
| 27 |
+
import json
|
| 28 |
+
import logging
|
| 29 |
+
import time
|
| 30 |
+
from datetime import datetime, UTC
|
| 31 |
+
from typing import Any, Optional
|
| 32 |
+
from uuid import uuid4
|
| 33 |
+
|
| 34 |
+
from fastapi import HTTPException, Request
|
| 35 |
+
|
| 36 |
+
log = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── Pricing per v4.0 §T34 ───────────────────────────────────────────
|
| 40 |
+
PRICING: dict[str, dict[str, Any]] = {
|
| 41 |
+
# free_tier: bool, pro_cents: int (USD cents), ent_cents: int
|
| 42 |
+
"get_token_risk": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Real-time token risk score"},
|
| 43 |
+
"get_wallet_analysis": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Wallet activity + reputation"},
|
| 44 |
+
"get_deployer_reputation": {"free_tier": False, "pro_cents": 2, "ent_cents": 1, "description": "Deployer reputation score"},
|
| 45 |
+
"get_news_sentiment": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Latest news + sentiment"},
|
| 46 |
+
"generate_report": {"free_tier": False, "pro_cents": 500, "ent_cents": 400, "description": "Full AI research report"},
|
| 47 |
+
"query_catalog": {"free_tier": False, "pro_cents": 5, "ent_cents": 3, "description": "Natural language catalog query"},
|
| 48 |
+
"find_similar_tokens": {"free_tier": False, "pro_cents": 3, "ent_cents": 2, "description": "Vector-similar tokens"},
|
| 49 |
+
"resolve_entity": {"free_tier": False, "pro_cents": 10, "ent_cents": 5, "description": "Cross-chain entity resolution"},
|
| 50 |
+
"search_rag": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "RAG semantic search"},
|
| 51 |
+
"bulk_ingest": {"free_tier": False, "pro_cents": 10, "ent_cents": 5, "description": "Bulk RAG ingestion"},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Free tier daily limit
|
| 55 |
+
FREE_DAILY_LIMIT = 5
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_tool_price_usd(tool: str) -> float:
|
| 59 |
+
"""Get pro tier price in USD. Returns 0 for free tools."""
|
| 60 |
+
p = PRICING.get(tool, {})
|
| 61 |
+
if p.get("free_tier", False):
|
| 62 |
+
return 0.0
|
| 63 |
+
return p.get("pro_cents", 0) / 100.0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_tool_metadata(tool: str) -> dict:
|
| 67 |
+
"""Public-facing tool metadata for /api/v1/x402/catalog."""
|
| 68 |
+
p = PRICING.get(tool, {})
|
| 69 |
+
return {
|
| 70 |
+
"name": tool,
|
| 71 |
+
"free_tier": p.get("free_tier", False),
|
| 72 |
+
"free_daily_limit": FREE_DAILY_LIMIT if p.get("free_tier") else 0,
|
| 73 |
+
"price_pro_usd": p.get("pro_cents", 0) / 100.0,
|
| 74 |
+
"price_ent_usd": p.get("ent_cents", 0) / 100.0,
|
| 75 |
+
"description": p.get("description", ""),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ── Rate limit tracking (Redis) ───────────────────────────────────
|
| 80 |
+
def _agent_id_from_request(request: Optional[Request]) -> str:
|
| 81 |
+
"""Extract agent id from headers. Defaults to 'anon:ip' for anonymous."""
|
| 82 |
+
if not request:
|
| 83 |
+
return "anon:unknown"
|
| 84 |
+
agent_id = request.headers.get("X-Agent-Id") or request.headers.get("X-Api-Key")
|
| 85 |
+
if agent_id:
|
| 86 |
+
return f"key:{agent_id}"
|
| 87 |
+
client = request.client
|
| 88 |
+
if client:
|
| 89 |
+
return f"anon:{client.host}"
|
| 90 |
+
return "anon:unknown"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def _check_rate_limit(redis, agent_id: str) -> None:
|
| 94 |
+
"""Sliding-window rate limit: 5 free calls/day, 1000 pro, unlimited ent.
|
| 95 |
+
|
| 96 |
+
For v1, we use Redis with a daily counter. Per-second limits are
|
| 97 |
+
enforced at the proxy level (nginx/Caddy).
|
| 98 |
+
"""
|
| 99 |
+
if not redis:
|
| 100 |
+
return
|
| 101 |
+
try:
|
| 102 |
+
day = datetime.now(UTC).strftime("%Y%m%d")
|
| 103 |
+
key = f"x402:rl:{agent_id}:{day}"
|
| 104 |
+
count = await redis.incr(key)
|
| 105 |
+
if count == 1:
|
| 106 |
+
await redis.expire(key, 86400)
|
| 107 |
+
if count > FREE_DAILY_LIMIT:
|
| 108 |
+
raise HTTPException(
|
| 109 |
+
status_code=429,
|
| 110 |
+
detail={
|
| 111 |
+
"error": "rate_limit_exceeded",
|
| 112 |
+
"agent_id": agent_id,
|
| 113 |
+
"daily_count": count,
|
| 114 |
+
"limit": FREE_DAILY_LIMIT,
|
| 115 |
+
"next_action": "Send X-Payment header with valid tx_hash to upgrade tier",
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
except HTTPException:
|
| 119 |
+
raise
|
| 120 |
+
except Exception as e:
|
| 121 |
+
log.warning(f"rate_limit_check_fail: {e}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ── Payment challenge ─────────────────────────────────────────────
|
| 125 |
+
def create_invoice(tool: str, agent_id: str) -> dict:
|
| 126 |
+
"""Create a payment challenge (signed invoice) for an agent to pay.
|
| 127 |
+
|
| 128 |
+
In v1, the signature is a HMAC placeholder. In production, the
|
| 129 |
+
invoice is signed by the platform's key and the agent verifies it
|
| 130 |
+
before paying.
|
| 131 |
+
"""
|
| 132 |
+
invoice_id = uuid4().hex
|
| 133 |
+
amount_usd = get_tool_price_usd(tool)
|
| 134 |
+
invoice = {
|
| 135 |
+
"id": invoice_id,
|
| 136 |
+
"tool": tool,
|
| 137 |
+
"agent_id": agent_id,
|
| 138 |
+
"amount_usd": amount_usd,
|
| 139 |
+
"amount_wei": int(amount_usd * 1e18 / 3000), # assume ETH = $3000
|
| 140 |
+
"pay_to": "0xRMI_PLATFORM_WALLET", # placeholder
|
| 141 |
+
"chain": "base", # L2 for low fees
|
| 142 |
+
"created_at": datetime.now(UTC).isoformat(),
|
| 143 |
+
"expires_at": (datetime.now(UTC).timestamp() + 3600),
|
| 144 |
+
}
|
| 145 |
+
# HMAC placeholder signature (real impl: sign with platform key)
|
| 146 |
+
msg = json.dumps(invoice, sort_keys=True).encode()
|
| 147 |
+
invoice["signature"] = base64.b64encode(
|
| 148 |
+
hashlib.sha256(msg + b"RMI_PLATFORM_HMAC_KEY").digest()
|
| 149 |
+
).decode()
|
| 150 |
+
return invoice
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def verify_payment_header(x_payment: str, expected_amount_usd: float) -> tuple[str, str] | None:
|
| 154 |
+
"""Decode the X-Payment header. Returns (tx_hash, signature) or None.
|
| 155 |
+
|
| 156 |
+
Format: base64({"tx_hash": "...", "signature": "..."})
|
| 157 |
+
"""
|
| 158 |
+
if not x_payment:
|
| 159 |
+
return None
|
| 160 |
+
try:
|
| 161 |
+
decoded = base64.b64decode(x_payment).decode()
|
| 162 |
+
data = json.loads(decoded)
|
| 163 |
+
return data.get("tx_hash", ""), data.get("signature", "")
|
| 164 |
+
except Exception:
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
async def verify_payment_on_chain(tx_hash: str, expected_amount_usd: float) -> bool:
|
| 169 |
+
"""Verify an on-chain payment. v1 stub — returns True for valid-format hashes.
|
| 170 |
+
|
| 171 |
+
Production: web3.py to query the chain, check recipient + amount.
|
| 172 |
+
"""
|
| 173 |
+
if not tx_hash or not tx_hash.startswith("0x") or len(tx_hash) != 66:
|
| 174 |
+
return False
|
| 175 |
+
# Real impl: check tx exists, recipient matches, amount >= expected
|
| 176 |
+
return True
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
async def record_receipt(catalog, tx_hash: str, tool: str, agent_id: str, amount_usd: float) -> bool:
|
| 180 |
+
"""Log the payment receipt to x402_receipts table."""
|
| 181 |
+
if not catalog._health.postgres:
|
| 182 |
+
return False
|
| 183 |
+
try:
|
| 184 |
+
async with catalog._pg_pool.acquire() as conn:
|
| 185 |
+
await conn.execute(
|
| 186 |
+
"""INSERT INTO x402_receipts (tx_hash, agent_id, tool, amount_usd, chain, paid_at, tier)
|
| 187 |
+
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
| 188 |
+
ON CONFLICT (tx_hash) DO NOTHING""",
|
| 189 |
+
tx_hash, agent_id, tool, amount_usd, "base", "pro",
|
| 190 |
+
)
|
| 191 |
+
return True
|
| 192 |
+
except Exception as e:
|
| 193 |
+
log.warning(f"receipt_record_fail: {e}")
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ── Main middleware ────────────────────────────────────────────────
|
| 198 |
+
async def require_payment(
|
| 199 |
+
tool: str,
|
| 200 |
+
request: Optional[Request] = None,
|
| 201 |
+
catalog=None,
|
| 202 |
+
) -> dict:
|
| 203 |
+
"""Verify x402 payment. Returns payment context dict.
|
| 204 |
+
|
| 205 |
+
Raises HTTPException 402 if payment is required but not provided.
|
| 206 |
+
Raises HTTPException 429 if rate limit is exceeded.
|
| 207 |
+
"""
|
| 208 |
+
agent_id = _agent_id_from_request(request)
|
| 209 |
+
if not catalog or not catalog._health.redis:
|
| 210 |
+
# No rate limiting available — log and pass
|
| 211 |
+
log.debug(f"x402_no_redis: {tool} by {agent_id}")
|
| 212 |
+
else:
|
| 213 |
+
await _check_rate_limit(catalog._redis, agent_id)
|
| 214 |
+
|
| 215 |
+
# Free tool — no payment required
|
| 216 |
+
if get_tool_price_usd(tool) == 0:
|
| 217 |
+
return {"agent_id": agent_id, "tool": tool, "tier": "free", "paid_via_x402": None}
|
| 218 |
+
|
| 219 |
+
# Paid tool — check X-Payment header
|
| 220 |
+
if request is None:
|
| 221 |
+
invoice = create_invoice(tool, agent_id)
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=402,
|
| 224 |
+
detail={
|
| 225 |
+
"error": "payment_required",
|
| 226 |
+
"invoice": invoice,
|
| 227 |
+
"payment_url": f"https://pay.rugmunch.io/{invoice['id']}",
|
| 228 |
+
"instructions": "Pay on-chain, then retry with X-Payment header (base64 of {tx_hash, signature})",
|
| 229 |
+
},
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
x_payment = request.headers.get("X-Payment")
|
| 233 |
+
if not x_payment:
|
| 234 |
+
invoice = create_invoice(tool, agent_id)
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
status_code=402,
|
| 237 |
+
detail={
|
| 238 |
+
"error": "payment_required",
|
| 239 |
+
"invoice": invoice,
|
| 240 |
+
"payment_url": f"https://pay.rugmunch.io/{invoice['id']}",
|
| 241 |
+
"instructions": "Pay on-chain, then retry with X-Payment header (base64 of {tx_hash, signature})",
|
| 242 |
+
},
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
decoded = verify_payment_header(x_payment, get_tool_price_usd(tool))
|
| 246 |
+
if not decoded:
|
| 247 |
+
raise HTTPException(status_code=402, detail="invalid X-Payment format")
|
| 248 |
+
tx_hash, _sig = decoded
|
| 249 |
+
if not await verify_payment_on_chain(tx_hash, get_tool_price_usd(tool)):
|
| 250 |
+
raise HTTPException(status_code=402, detail="invalid payment proof")
|
| 251 |
+
|
| 252 |
+
# Record the receipt
|
| 253 |
+
if catalog:
|
| 254 |
+
await record_receipt(
|
| 255 |
+
catalog, tx_hash, tool, agent_id, get_tool_price_usd(tool)
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
"agent_id": agent_id,
|
| 260 |
+
"tool": tool,
|
| 261 |
+
"tier": "pro",
|
| 262 |
+
"paid_via_x402": tx_hash,
|
| 263 |
+
}
|
backend/app/domain/x402/router.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T34 x402 Paid Tools Catalog.
|
| 2 |
+
|
| 3 |
+
Per v4.0 §T34. Endpoints:
|
| 4 |
+
GET /api/v1/x402/catalog — list all tools with pricing tiers
|
| 5 |
+
GET /api/v1/x402/usage — per-agent usage stats
|
| 6 |
+
GET /api/v1/x402/receipts/{tx_hash} — payment receipt
|
| 7 |
+
POST /api/v1/x402/verify — verify a payment header
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
from app.catalog.service import get_catalog
|
| 18 |
+
from app.domain.x402.middleware import (
|
| 19 |
+
PRICING,
|
| 20 |
+
get_tool_metadata,
|
| 21 |
+
get_tool_price_usd,
|
| 22 |
+
verify_payment_header,
|
| 23 |
+
verify_payment_on_chain,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
router = APIRouter(prefix="/api/v1/x402", tags=["x402"])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CatalogResponse(BaseModel):
|
| 30 |
+
server: str = "rugmunch-intelligence"
|
| 31 |
+
version: str = "4.0"
|
| 32 |
+
tools: list[dict[str, Any]] = Field(default_factory=list)
|
| 33 |
+
payment_protocol: str = "x402"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class UsageResponse(BaseModel):
|
| 37 |
+
agent_id: str
|
| 38 |
+
daily_calls: int
|
| 39 |
+
free_limit: int
|
| 40 |
+
paid_calls: int
|
| 41 |
+
total_usd: float
|
| 42 |
+
recent_receipts: list[dict[str, Any]] = Field(default_factory=list)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ReceiptResponse(BaseModel):
|
| 46 |
+
tx_hash: str
|
| 47 |
+
tool: str
|
| 48 |
+
agent_id: Optional[str] = None
|
| 49 |
+
amount_usd: float
|
| 50 |
+
chain: Optional[str] = None
|
| 51 |
+
paid_at: Optional[str] = None
|
| 52 |
+
tier: Optional[str] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/catalog", response_model=CatalogResponse)
|
| 56 |
+
async def catalog() -> CatalogResponse:
|
| 57 |
+
"""List all paid tools with pricing tiers."""
|
| 58 |
+
return CatalogResponse(tools=[get_tool_metadata(t) for t in PRICING.keys()])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/usage", response_model=UsageResponse)
|
| 62 |
+
async def usage(request: Request) -> UsageResponse:
|
| 63 |
+
"""Per-agent usage stats: calls made, $ spent, top tools."""
|
| 64 |
+
catalog = get_catalog()
|
| 65 |
+
await catalog._init_stores()
|
| 66 |
+
agent_id = request.headers.get("X-Agent-Id") or request.headers.get("X-Api-Key") or "anon"
|
| 67 |
+
daily_calls = 0
|
| 68 |
+
paid_calls = 0
|
| 69 |
+
total_usd = 0.0
|
| 70 |
+
recent: list[dict] = []
|
| 71 |
+
if catalog._health.redis:
|
| 72 |
+
try:
|
| 73 |
+
from datetime import datetime, UTC
|
| 74 |
+
day = datetime.now(UTC).strftime("%Y%m%d")
|
| 75 |
+
key = f"x402:rl:{agent_id}:{day}"
|
| 76 |
+
daily_calls = int(await catalog._redis.get(key) or 0)
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
if catalog._health.postgres:
|
| 80 |
+
try:
|
| 81 |
+
async with catalog._pg_pool.acquire() as conn:
|
| 82 |
+
rows = await conn.fetch(
|
| 83 |
+
"SELECT tx_hash, tool, amount_usd, paid_at FROM x402_receipts "
|
| 84 |
+
"WHERE agent_id = $1 ORDER BY paid_at DESC LIMIT 10",
|
| 85 |
+
agent_id,
|
| 86 |
+
)
|
| 87 |
+
for r in rows:
|
| 88 |
+
paid_calls += 1
|
| 89 |
+
total_usd += float(r["amount_usd"] or 0)
|
| 90 |
+
recent.append({
|
| 91 |
+
"tx_hash": r["tx_hash"],
|
| 92 |
+
"tool": r["tool"],
|
| 93 |
+
"amount_usd": float(r["amount_usd"] or 0),
|
| 94 |
+
"paid_at": r["paid_at"].isoformat() if r["paid_at"] else None,
|
| 95 |
+
})
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logging.getLogger(__name__).warning(f"usage_query_fail: {e}")
|
| 98 |
+
return UsageResponse(
|
| 99 |
+
agent_id=agent_id, daily_calls=daily_calls, free_limit=5,
|
| 100 |
+
paid_calls=paid_calls, total_usd=round(total_usd, 4),
|
| 101 |
+
recent_receipts=recent,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/receipts/{tx_hash}", response_model=ReceiptResponse)
|
| 106 |
+
async def receipt(tx_hash: str) -> ReceiptResponse:
|
| 107 |
+
"""Payment receipt for a specific on-chain tx."""
|
| 108 |
+
catalog = get_catalog()
|
| 109 |
+
await catalog._init_stores()
|
| 110 |
+
if not catalog._health.postgres:
|
| 111 |
+
raise HTTPException(503, "postgres unavailable")
|
| 112 |
+
try:
|
| 113 |
+
async with catalog._pg_pool.acquire() as conn:
|
| 114 |
+
r = await conn.fetchrow(
|
| 115 |
+
"SELECT * FROM x402_receipts WHERE tx_hash=$1", tx_hash
|
| 116 |
+
)
|
| 117 |
+
if not r:
|
| 118 |
+
raise HTTPException(404, "receipt not found")
|
| 119 |
+
d = dict(r)
|
| 120 |
+
return ReceiptResponse(
|
| 121 |
+
tx_hash=d["tx_hash"],
|
| 122 |
+
tool=d["tool"],
|
| 123 |
+
agent_id=d.get("agent_id"),
|
| 124 |
+
amount_usd=float(d.get("amount_usd", 0)),
|
| 125 |
+
chain=d.get("chain"),
|
| 126 |
+
paid_at=d["paid_at"].isoformat() if d.get("paid_at") else None,
|
| 127 |
+
tier=d.get("tier"),
|
| 128 |
+
)
|
| 129 |
+
except HTTPException:
|
| 130 |
+
raise
|
| 131 |
+
except Exception as e:
|
| 132 |
+
raise HTTPException(500, f"receipt_query_fail: {e}")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.post("/verify")
|
| 136 |
+
async def verify_payment(request: Request) -> dict:
|
| 137 |
+
"""Verify an X-Payment header for a given tool."""
|
| 138 |
+
body = await request.json()
|
| 139 |
+
tool = body.get("tool", "")
|
| 140 |
+
x_payment = body.get("x_payment", "")
|
| 141 |
+
if tool not in PRICING:
|
| 142 |
+
raise HTTPException(404, f"unknown tool: {tool}")
|
| 143 |
+
if get_tool_price_usd(tool) == 0:
|
| 144 |
+
return {"valid": True, "tier": "free", "amount_usd": 0}
|
| 145 |
+
decoded = verify_payment_header(x_payment, get_tool_price_usd(tool))
|
| 146 |
+
if not decoded:
|
| 147 |
+
return {"valid": False, "reason": "invalid format"}
|
| 148 |
+
tx_hash, _ = decoded
|
| 149 |
+
valid = await verify_payment_on_chain(tx_hash, get_tool_price_usd(tool))
|
| 150 |
+
return {
|
| 151 |
+
"valid": valid,
|
| 152 |
+
"tool": tool,
|
| 153 |
+
"amount_usd": get_tool_price_usd(tool),
|
| 154 |
+
"tx_hash": tx_hash,
|
| 155 |
+
}
|
backend/app/mcp/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCP Server Modules
|
| 2 |
+
# ==================
|
| 3 |
+
# x402 gateways run on Cloudflare Workers (7 chains, 45 tools each)
|
| 4 |
+
# The port 8001 local server was shut down - CF Workers handle everything
|
| 5 |
+
# Backend is at /srv/rugmuncher-backend/backend/ (Docker container rmi_backend)
|
| 6 |
+
|
| 7 |
+
# Legacy reference only - do not import for production use
|
| 8 |
+
# from .x402_mcp_server import X402MCPServer, X402ToolManager
|
| 9 |
+
|
| 10 |
+
__all__ = []
|
backend/app/mcp/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (144 Bytes). View file
|
|
|
backend/app/mcp/__pycache__/server.cpython-311.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
backend/app/mcp/server.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T33 MCP Server — exposes 8 tools to AI agents at mcp.rugmunch.io.
|
| 2 |
+
|
| 3 |
+
Per v4.0 §T33. JSON-RPC over SSE (the protocol Claude/Cursor speak).
|
| 4 |
+
|
| 5 |
+
Tools (per v4.0):
|
| 6 |
+
1. get_token_risk — Real-time risk score (FREE 5/day or $0.01)
|
| 7 |
+
2. get_wallet_analysis — Wallet activity + reputation
|
| 8 |
+
3. get_deployer_reputation — Deployer reputation (0-100)
|
| 9 |
+
4. get_news_sentiment — Latest news + sentiment
|
| 10 |
+
5. generate_report — Full AI research report ($5)
|
| 11 |
+
6. query_catalog — Natural language catalog query
|
| 12 |
+
7. find_similar_tokens — Vector-similar tokens
|
| 13 |
+
8. resolve_entity — Cross-chain entity resolution
|
| 14 |
+
|
| 15 |
+
Backend implementations: app/catalog/* + app/domain/reports/generator.py
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Tool catalog — inputSchema follows JSON Schema 2020-12
|
| 26 |
+
TOOL_CATALOG: list[dict[str, Any]] = [
|
| 27 |
+
{
|
| 28 |
+
"name": "get_token_risk",
|
| 29 |
+
"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.",
|
| 30 |
+
"inputSchema": {
|
| 31 |
+
"type": "object",
|
| 32 |
+
"properties": {
|
| 33 |
+
"chain": {"type": "string", "enum": ["solana", "ethereum", "base", "arbitrum", "optimism", "polygon", "bsc", "tron", "bitcoin", "avalanche", "fantom", "gnosis"]},
|
| 34 |
+
"address": {"type": "string", "description": "Token contract address"},
|
| 35 |
+
},
|
| 36 |
+
"required": ["chain", "address"],
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "get_wallet_analysis",
|
| 41 |
+
"description": "Wallet activity, balance, transaction history, and reputation. Returns wallet profile + risk flags.",
|
| 42 |
+
"inputSchema": {
|
| 43 |
+
"type": "object",
|
| 44 |
+
"properties": {
|
| 45 |
+
"chain": {"type": "string"},
|
| 46 |
+
"address": {"type": "string"},
|
| 47 |
+
},
|
| 48 |
+
"required": ["chain", "address"],
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "get_deployer_reputation",
|
| 53 |
+
"description": "Deployer reputation score 0-100 (100=clean, 0=serial rugger). Deterministic from on-chain history + news + RAG findings. Cached 1h.",
|
| 54 |
+
"inputSchema": {
|
| 55 |
+
"type": "object",
|
| 56 |
+
"properties": {
|
| 57 |
+
"chain": {"type": "string"},
|
| 58 |
+
"address": {"type": "string"},
|
| 59 |
+
},
|
| 60 |
+
"required": ["chain", "address"],
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"name": "get_news_sentiment",
|
| 65 |
+
"description": "Latest news for a token or wallet with sentiment classification. Returns articles + composite sentiment score.",
|
| 66 |
+
"inputSchema": {
|
| 67 |
+
"type": "object",
|
| 68 |
+
"properties": {
|
| 69 |
+
"subject_id": {"type": "string", "description": "chain:address, or 'all' for general news"},
|
| 70 |
+
"since_hours": {"type": "integer", "default": 24, "minimum": 1, "maximum": 720},
|
| 71 |
+
"limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 50},
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"name": "generate_report",
|
| 77 |
+
"description": "Full AI research report on a token or wallet. 7 sections composed in parallel via LLM. $5/report. Returns full Markdown.",
|
| 78 |
+
"inputSchema": {
|
| 79 |
+
"type": "object",
|
| 80 |
+
"properties": {
|
| 81 |
+
"subject_type": {"type": "string", "enum": ["token", "wallet"]},
|
| 82 |
+
"subject_id": {"type": "string", "description": "chain:address"},
|
| 83 |
+
},
|
| 84 |
+
"required": ["subject_type", "subject_id"],
|
| 85 |
+
},
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"name": "query_catalog",
|
| 89 |
+
"description": "Natural language catalog query. Returns matching tokens, wallets, deployers, news, RAG findings. $0.05/query.",
|
| 90 |
+
"inputSchema": {
|
| 91 |
+
"type": "object",
|
| 92 |
+
"properties": {
|
| 93 |
+
"query": {"type": "string", "description": "Natural language question"},
|
| 94 |
+
},
|
| 95 |
+
"required": ["query"],
|
| 96 |
+
},
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"name": "find_similar_tokens",
|
| 100 |
+
"description": "Vector-similar tokens to a given token. Returns tokens with cosine similarity >= 0.85. $0.03/query.",
|
| 101 |
+
"inputSchema": {
|
| 102 |
+
"type": "object",
|
| 103 |
+
"properties": {
|
| 104 |
+
"chain": {"type": "string"},
|
| 105 |
+
"address": {"type": "string"},
|
| 106 |
+
"limit": {"type": "integer", "default": 10, "maximum": 50},
|
| 107 |
+
},
|
| 108 |
+
"required": ["chain", "address"],
|
| 109 |
+
},
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"name": "resolve_entity",
|
| 113 |
+
"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.",
|
| 114 |
+
"inputSchema": {
|
| 115 |
+
"type": "object",
|
| 116 |
+
"properties": {
|
| 117 |
+
"wallet_id": {"type": "string", "description": "chain:address"},
|
| 118 |
+
},
|
| 119 |
+
"required": ["wallet_id"],
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── Tool implementations ──────────────────────────────────────────
|
| 126 |
+
async def call_tool(name: str, arguments: dict) -> dict:
|
| 127 |
+
"""Dispatch a tool call to the appropriate backend."""
|
| 128 |
+
from app.catalog.service import get_catalog
|
| 129 |
+
|
| 130 |
+
catalog = get_catalog()
|
| 131 |
+
await catalog._init_stores()
|
| 132 |
+
|
| 133 |
+
if name == "get_token_risk":
|
| 134 |
+
from app.catalog.models import Chain
|
| 135 |
+
try:
|
| 136 |
+
c = Chain(arguments["chain"])
|
| 137 |
+
except ValueError:
|
| 138 |
+
return {"error": f"unknown chain: {arguments['chain']}"}
|
| 139 |
+
result = await catalog.get_token_risk(c, arguments["address"])
|
| 140 |
+
return {"result": result, "tier": "free_or_pro"}
|
| 141 |
+
|
| 142 |
+
if name == "get_wallet_analysis":
|
| 143 |
+
from app.catalog.models import Chain
|
| 144 |
+
try:
|
| 145 |
+
c = Chain(arguments["chain"])
|
| 146 |
+
except ValueError:
|
| 147 |
+
return {"error": f"unknown chain: {arguments['chain']}"}
|
| 148 |
+
w = await catalog.get_wallet(c, arguments["address"])
|
| 149 |
+
if not w:
|
| 150 |
+
return {"error": "wallet not found in catalog"}
|
| 151 |
+
return {"result": w.model_dump(mode="json")}
|
| 152 |
+
|
| 153 |
+
if name == "get_deployer_reputation":
|
| 154 |
+
from app.catalog.models import Chain
|
| 155 |
+
try:
|
| 156 |
+
c = Chain(arguments["chain"])
|
| 157 |
+
except ValueError:
|
| 158 |
+
return {"error": f"unknown chain: {arguments['chain']}"}
|
| 159 |
+
w = await catalog.get_wallet(c, arguments["address"])
|
| 160 |
+
if not w:
|
| 161 |
+
return {"error": "deployer wallet not found", "reputation_score": 50}
|
| 162 |
+
# Compute reputation deterministically
|
| 163 |
+
from app.catalog.reputation import compute_deployer_reputation
|
| 164 |
+
from app.catalog.models import Deployer
|
| 165 |
+
deployer = Deployer(
|
| 166 |
+
wallet_id=w.wallet_id, chain=w.chain, address=w.address,
|
| 167 |
+
first_seen=w.first_seen, last_seen=w.last_seen,
|
| 168 |
+
tx_count=w.tx_count, total_volume_usd=w.total_volume_usd,
|
| 169 |
+
is_deployer=True, reputation_score=w.reputation_score,
|
| 170 |
+
deployments=getattr(w, "deployments", []),
|
| 171 |
+
rug_count=getattr(w, "rug_count", 0),
|
| 172 |
+
)
|
| 173 |
+
score = await compute_deployer_reputation(deployer, catalog)
|
| 174 |
+
return {"result": {"reputation_score": score, "tier": _tier_from_score(score)}}
|
| 175 |
+
|
| 176 |
+
if name == "get_news_sentiment":
|
| 177 |
+
subject = arguments.get("subject_id", "all")
|
| 178 |
+
since = int(arguments.get("since_hours", 24))
|
| 179 |
+
limit = int(arguments.get("limit", 10))
|
| 180 |
+
if not catalog._health.postgres:
|
| 181 |
+
return {"error": "postgres unavailable", "articles": []}
|
| 182 |
+
try:
|
| 183 |
+
async with catalog._pg_pool.acquire() as conn:
|
| 184 |
+
rows = await conn.fetch(
|
| 185 |
+
"""SELECT news_id, title, summary, source, published_at, sentiment_score
|
| 186 |
+
FROM news_items
|
| 187 |
+
WHERE published_at > NOW() - ($1 || ' hours')::interval
|
| 188 |
+
ORDER BY published_at DESC LIMIT $2""",
|
| 189 |
+
str(since), limit,
|
| 190 |
+
)
|
| 191 |
+
articles = [
|
| 192 |
+
{
|
| 193 |
+
"news_id": r["news_id"],
|
| 194 |
+
"title": r["title"],
|
| 195 |
+
"summary": (r["summary"] or "")[:200],
|
| 196 |
+
"source": r["source"],
|
| 197 |
+
"published_at": r["published_at"].isoformat(),
|
| 198 |
+
"sentiment_score": r["sentiment_score"],
|
| 199 |
+
}
|
| 200 |
+
for r in rows
|
| 201 |
+
]
|
| 202 |
+
avg_sent = sum(a["sentiment_score"] or 0 for a in articles) / max(1, len(articles))
|
| 203 |
+
return {"result": {
|
| 204 |
+
"subject": subject,
|
| 205 |
+
"article_count": len(articles),
|
| 206 |
+
"avg_sentiment": round(avg_sent, 3),
|
| 207 |
+
"articles": articles,
|
| 208 |
+
}}
|
| 209 |
+
except Exception as e:
|
| 210 |
+
return {"error": f"news_query_fail: {e}"}
|
| 211 |
+
|
| 212 |
+
if name == "generate_report":
|
| 213 |
+
from app.domain.reports.generator import generate_token_report, generate_wallet_report
|
| 214 |
+
chain, address = arguments["subject_id"].split(":", 1)
|
| 215 |
+
try:
|
| 216 |
+
if arguments["subject_type"] == "token":
|
| 217 |
+
report = await generate_token_report(catalog, chain, address)
|
| 218 |
+
else:
|
| 219 |
+
report = await generate_wallet_report(catalog, chain, address)
|
| 220 |
+
from app.domain.reports.generator import save_report
|
| 221 |
+
await save_report(catalog, report)
|
| 222 |
+
return {"result": {
|
| 223 |
+
"report_id": report.report_id,
|
| 224 |
+
"risk_score": report.risk_score,
|
| 225 |
+
"risk_tier": report.risk_tier.value,
|
| 226 |
+
"markdown": report.to_markdown(),
|
| 227 |
+
"paid_via_x402": None, # MCP doesn't enforce payment in v1
|
| 228 |
+
}}
|
| 229 |
+
except Exception as e:
|
| 230 |
+
return {"error": f"report_fail: {e}"}
|
| 231 |
+
|
| 232 |
+
if name == "query_catalog":
|
| 233 |
+
# NL query -> RAG search
|
| 234 |
+
q = arguments.get("query", "")
|
| 235 |
+
hits = await catalog.rag_search(query=q, top_k=5)
|
| 236 |
+
return {"result": {"query": q, "hits": hits, "count": len(hits)}}
|
| 237 |
+
|
| 238 |
+
if name == "find_similar_tokens":
|
| 239 |
+
from app.catalog.models import Chain
|
| 240 |
+
try:
|
| 241 |
+
c = Chain(arguments["chain"])
|
| 242 |
+
except ValueError:
|
| 243 |
+
return {"error": f"unknown chain: {arguments['chain']}"}
|
| 244 |
+
# Use token's rag_embedding_id to find similar via Qdrant
|
| 245 |
+
token = await catalog.get_token(c, arguments["address"])
|
| 246 |
+
if not token or not token.rag_embedding_id:
|
| 247 |
+
return {"error": "token not in catalog or no RAG embedding", "similar": []}
|
| 248 |
+
# Use RAG to search for similar by querying with the token's content
|
| 249 |
+
rag_hits = await catalog.rag_search(query=token.symbol or "token", top_k=int(arguments.get("limit", 10)))
|
| 250 |
+
return {"result": {"subject": arguments["address"], "similar": rag_hits[:10]}}
|
| 251 |
+
|
| 252 |
+
if name == "resolve_entity":
|
| 253 |
+
result = await catalog.resolve_entity(arguments["wallet_id"])
|
| 254 |
+
return {"result": result}
|
| 255 |
+
|
| 256 |
+
return {"error": f"unknown tool: {name}"}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _tier_from_score(score: int) -> str:
|
| 260 |
+
if score < 25:
|
| 261 |
+
return "low"
|
| 262 |
+
if score < 50:
|
| 263 |
+
return "medium"
|
| 264 |
+
if score < 75:
|
| 265 |
+
return "high"
|
| 266 |
+
return "critical"
|
backend/app/mcp/x402_mcp_server.py
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
RMI x402 MCP Server v5.0 - COMPLETE SYSTEM
|
| 4 |
+
==========================================
|
| 5 |
+
|
| 6 |
+
Complete x402 micropayment gateway with:
|
| 7 |
+
- Free/Trial/Premium tier system
|
| 8 |
+
- Multi-chain support (Base + Solana)
|
| 9 |
+
- AI Guard middleware integration
|
| 10 |
+
- 15+ tool bundles with intelligence suite
|
| 11 |
+
- Solana & Ethereum MCP server integration
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import os
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from datetime import datetime, timedelta
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import uvicorn
|
| 21 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 22 |
+
from fastapi.responses import JSONResponse
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("x402_mcp_server")
|
| 25 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ============================================================
|
| 29 |
+
# AI GUARD INTEGRATION (works even without app.security module)
|
| 30 |
+
# ============================================================
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AIGuardWrapper:
|
| 34 |
+
"""AI Guard that works standalone or with app.security."""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
self.active = False
|
| 38 |
+
self.blocked_patterns = [
|
| 39 |
+
"drop table",
|
| 40 |
+
"delete from",
|
| 41 |
+
"insert into",
|
| 42 |
+
"--",
|
| 43 |
+
"/*",
|
| 44 |
+
"xp_",
|
| 45 |
+
"union select",
|
| 46 |
+
"exec(",
|
| 47 |
+
"eval(",
|
| 48 |
+
"<script",
|
| 49 |
+
"javascript:",
|
| 50 |
+
"onload=",
|
| 51 |
+
"onerror=",
|
| 52 |
+
"document.cookie",
|
| 53 |
+
"localStorage",
|
| 54 |
+
"SELECT * FROM",
|
| 55 |
+
"DROP TABLE",
|
| 56 |
+
"INSERT INTO",
|
| 57 |
+
"DELETE FROM",
|
| 58 |
+
]
|
| 59 |
+
self._load_external_guard()
|
| 60 |
+
|
| 61 |
+
def _load_external_guard(self):
|
| 62 |
+
"""Try to load RugMunch's actual AI Guard if available."""
|
| 63 |
+
try:
|
| 64 |
+
import sys
|
| 65 |
+
|
| 66 |
+
sys.path.insert(0, "/srv/rugmuncher-backend/backend/app")
|
| 67 |
+
from security.ai_guard import AIGuard
|
| 68 |
+
|
| 69 |
+
self._guard = AIGuard()
|
| 70 |
+
self.active = True
|
| 71 |
+
logger.info("✅ AI Guard loaded from RugMunch security module")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.warning(f"⚠️ Using standalone AI Guard (external module unavailable: {e})")
|
| 74 |
+
self._guard = None
|
| 75 |
+
self.active = True # Standalone still works
|
| 76 |
+
|
| 77 |
+
async def check_request(self, request: Request) -> tuple[bool, str | None]:
|
| 78 |
+
"""Check request for security violations."""
|
| 79 |
+
# Check headers
|
| 80 |
+
user_agent = request.headers.get("User-Agent", "").lower()
|
| 81 |
+
if any(bot in user_agent for bot in ["sqlmap", "nikto", "nmap", "masscan", "zgrab"]):
|
| 82 |
+
return False, "Security scanner detected"
|
| 83 |
+
|
| 84 |
+
# Check body for POST/PUT/PATCH
|
| 85 |
+
if request.method in ("POST", "PUT", "PATCH"):
|
| 86 |
+
try:
|
| 87 |
+
body = await request.body()
|
| 88 |
+
body_str = body.decode("utf-8", errors="ignore").lower()
|
| 89 |
+
for pattern in self.blocked_patterns:
|
| 90 |
+
if pattern in body_str:
|
| 91 |
+
return False, f"Malicious pattern detected: {pattern}"
|
| 92 |
+
except:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
return True, None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ============================================================
|
| 99 |
+
# DATA MODELS
|
| 100 |
+
# ============================================================
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@dataclass
|
| 104 |
+
class X402Bundle:
|
| 105 |
+
"""Complete x402 tool bundle definition."""
|
| 106 |
+
|
| 107 |
+
bundle_id: str
|
| 108 |
+
name: str
|
| 109 |
+
description: str
|
| 110 |
+
category: str
|
| 111 |
+
endpoints: list[dict[str, Any]] = field(default_factory=list)
|
| 112 |
+
price_usd: float = 0.0
|
| 113 |
+
tier: str = "standard" # free, trial, standard, premium, enterprise
|
| 114 |
+
is_premium: bool = False
|
| 115 |
+
requires_payment: bool = True
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def base_eth(self) -> str:
|
| 119 |
+
return f"{self.price_usd / 3000:.6f} ETH"
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def solana_usdc(self) -> str:
|
| 123 |
+
return f"{self.price_usd:.2f} USDC"
|
| 124 |
+
|
| 125 |
+
def dict(self) -> dict[str, Any]:
|
| 126 |
+
return {
|
| 127 |
+
"bundle_id": self.bundle_id,
|
| 128 |
+
"name": self.name,
|
| 129 |
+
"description": self.description,
|
| 130 |
+
"category": self.category,
|
| 131 |
+
"endpoints": self.endpoints,
|
| 132 |
+
"endpoint_count": len(self.endpoints),
|
| 133 |
+
"price_usd": self.price_usd,
|
| 134 |
+
"tier": self.tier,
|
| 135 |
+
"is_premium": self.is_premium,
|
| 136 |
+
"requires_payment": self.requires_payment,
|
| 137 |
+
"pricing": {"base": self.base_eth, "solana": self.solana_usdc},
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ============================================================
|
| 142 |
+
# FACILITATOR CONFIG
|
| 143 |
+
# ============================================================
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class MultiChainFacilitator:
|
| 147 |
+
"""Multi-chain payment facilitator."""
|
| 148 |
+
|
| 149 |
+
base_config = {
|
| 150 |
+
"name": "RugMunch Intelligence x402 Gateway",
|
| 151 |
+
"network": "BASE",
|
| 152 |
+
"wallet_address": os.environ.get("X402_BASE_WALLET", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"),
|
| 153 |
+
"token_symbol": "ETH",
|
| 154 |
+
"token_decimals": 18,
|
| 155 |
+
"rpc_url": "https://mainnet.base.org",
|
| 156 |
+
"explorer": "https://basescan.org",
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
solana_config = {
|
| 160 |
+
"name": "RugMunch Intelligence x402 Gateway",
|
| 161 |
+
"network": "SOLANA",
|
| 162 |
+
"wallet_address": os.environ.get("X402_SOLANA_WALLET", "Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv"),
|
| 163 |
+
"token_symbol": "USDC",
|
| 164 |
+
"token_decimals": 6,
|
| 165 |
+
"rpc_url": "https://api.mainnet-beta.solana.com",
|
| 166 |
+
"explorer": "https://solscan.io",
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
third_party = {
|
| 170 |
+
"base": "https://mcp.rugmunch.io",
|
| 171 |
+
"solana": "https://mcp.rugmunch.io",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ============================================================
|
| 176 |
+
# TOOL MANAGER - COMPLETE CATALOG
|
| 177 |
+
# ============================================================
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class X402ToolManager:
|
| 181 |
+
"""Manages all x402 tool bundles including free/trial/premium."""
|
| 182 |
+
|
| 183 |
+
def __init__(self):
|
| 184 |
+
self._bundles = self._load_all_bundles()
|
| 185 |
+
self._index = {b.bundle_id: b for b in self._bundles}
|
| 186 |
+
self._index.update({b.name.lower(): b for b in self._bundles})
|
| 187 |
+
|
| 188 |
+
def _load_all_bundles(self) -> list[X402Bundle]:
|
| 189 |
+
"""Load complete catalog: free, trial, standard, premium."""
|
| 190 |
+
|
| 191 |
+
return [
|
| 192 |
+
# ========== FREE TIER ==========
|
| 193 |
+
X402Bundle(
|
| 194 |
+
bundle_id="free-health",
|
| 195 |
+
name="Free Health Check",
|
| 196 |
+
description="Free system health and status endpoints",
|
| 197 |
+
category="free",
|
| 198 |
+
endpoints=[
|
| 199 |
+
{"method": "GET", "path": "/health", "description": "System health"},
|
| 200 |
+
{"method": "GET", "path": "/status", "description": "System status"},
|
| 201 |
+
{"method": "GET", "path": "/version", "description": "API version"},
|
| 202 |
+
],
|
| 203 |
+
price_usd=0.0,
|
| 204 |
+
tier="free",
|
| 205 |
+
is_premium=False,
|
| 206 |
+
requires_payment=False,
|
| 207 |
+
),
|
| 208 |
+
X402Bundle(
|
| 209 |
+
bundle_id="free-discovery",
|
| 210 |
+
name="Free Tool Discovery",
|
| 211 |
+
description="Discover all available tools without payment",
|
| 212 |
+
category="free",
|
| 213 |
+
endpoints=[
|
| 214 |
+
{"method": "GET", "path": "/.well-known/x402", "description": "x402 discovery"},
|
| 215 |
+
{"method": "GET", "path": "/v1/bundles", "description": "List all bundles"},
|
| 216 |
+
{"method": "GET", "path": "/v1/bundles/{id}", "description": "Bundle details"},
|
| 217 |
+
],
|
| 218 |
+
price_usd=0.0,
|
| 219 |
+
tier="free",
|
| 220 |
+
is_premium=False,
|
| 221 |
+
requires_payment=False,
|
| 222 |
+
),
|
| 223 |
+
# ========== TRIAL TIER ==========
|
| 224 |
+
X402Bundle(
|
| 225 |
+
bundle_id="trial-security",
|
| 226 |
+
name="Trial Security Suite",
|
| 227 |
+
description="7-day trial of premium security tools",
|
| 228 |
+
category="trial",
|
| 229 |
+
endpoints=[
|
| 230 |
+
{
|
| 231 |
+
"method": "POST",
|
| 232 |
+
"path": "/scan/contract",
|
| 233 |
+
"description": "Smart contract scan",
|
| 234 |
+
},
|
| 235 |
+
{"method": "POST", "path": "/scan/token", "description": "Token risk scan"},
|
| 236 |
+
{"method": "POST", "path": "/scan/wallet", "description": "Wallet risk scan"},
|
| 237 |
+
],
|
| 238 |
+
price_usd=0.0,
|
| 239 |
+
tier="trial",
|
| 240 |
+
is_premium=False,
|
| 241 |
+
requires_payment=False,
|
| 242 |
+
),
|
| 243 |
+
X402Bundle(
|
| 244 |
+
bundle_id="trial-intelligence",
|
| 245 |
+
name="Trial Intelligence Suite",
|
| 246 |
+
description="7-day trial of market intelligence tools",
|
| 247 |
+
category="trial",
|
| 248 |
+
endpoints=[
|
| 249 |
+
{"method": "GET", "path": "/market/pulse", "description": "Token pulse"},
|
| 250 |
+
{"method": "GET", "path": "/market/trends", "description": "Market trends"},
|
| 251 |
+
{"method": "GET", "path": "/market/whales", "description": "Whale tracking"},
|
| 252 |
+
],
|
| 253 |
+
price_usd=0.0,
|
| 254 |
+
tier="trial",
|
| 255 |
+
is_premium=False,
|
| 256 |
+
requires_payment=False,
|
| 257 |
+
),
|
| 258 |
+
# ========== STANDARD TIER ==========
|
| 259 |
+
X402Bundle(
|
| 260 |
+
bundle_id="token-pulse",
|
| 261 |
+
name="Token Pulse",
|
| 262 |
+
description="Real-time market momentum, volume, whale alerts",
|
| 263 |
+
category="market",
|
| 264 |
+
endpoints=[
|
| 265 |
+
{"method": "GET", "path": "/tokens", "description": "Token listings"},
|
| 266 |
+
{"method": "GET", "path": "/tokens/{address}", "description": "Token details"},
|
| 267 |
+
{
|
| 268 |
+
"method": "GET",
|
| 269 |
+
"path": "/lp/{address}",
|
| 270 |
+
"description": "Liquidity pool analysis",
|
| 271 |
+
},
|
| 272 |
+
],
|
| 273 |
+
price_usd=0.01,
|
| 274 |
+
tier="standard",
|
| 275 |
+
),
|
| 276 |
+
X402Bundle(
|
| 277 |
+
bundle_id="url-scam-detector",
|
| 278 |
+
name="URL Scam Detector",
|
| 279 |
+
description="Detect phishing sites, fake docs, malicious redirects",
|
| 280 |
+
category="security",
|
| 281 |
+
endpoints=[
|
| 282 |
+
{"method": "POST", "path": "/scan/url", "description": "URL scan"},
|
| 283 |
+
{"method": "POST", "path": "/scan/domain", "description": "Domain analysis"},
|
| 284 |
+
],
|
| 285 |
+
price_usd=0.01,
|
| 286 |
+
tier="standard",
|
| 287 |
+
),
|
| 288 |
+
X402Bundle(
|
| 289 |
+
bundle_id="wallet-profiler",
|
| 290 |
+
name="Wallet Profiler",
|
| 291 |
+
description="Full wallet analysis with persona detection",
|
| 292 |
+
category="wallet",
|
| 293 |
+
endpoints=[
|
| 294 |
+
{"method": "POST", "path": "/wallet/profile", "description": "Profile wallet"},
|
| 295 |
+
{
|
| 296 |
+
"method": "POST",
|
| 297 |
+
"path": "/wallet/history",
|
| 298 |
+
"description": "Transaction history",
|
| 299 |
+
},
|
| 300 |
+
],
|
| 301 |
+
price_usd=0.05,
|
| 302 |
+
tier="standard",
|
| 303 |
+
),
|
| 304 |
+
X402Bundle(
|
| 305 |
+
bundle_id="social-sentiment",
|
| 306 |
+
name="Social Sentiment",
|
| 307 |
+
description="Cross-platform sentiment analysis",
|
| 308 |
+
category="market",
|
| 309 |
+
endpoints=[
|
| 310 |
+
{
|
| 311 |
+
"method": "POST",
|
| 312 |
+
"path": "/sentiment/analyze",
|
| 313 |
+
"description": "Analyze sentiment",
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"method": "GET",
|
| 317 |
+
"path": "/sentiment/trends",
|
| 318 |
+
"description": "Trending topics",
|
| 319 |
+
},
|
| 320 |
+
],
|
| 321 |
+
price_usd=0.03,
|
| 322 |
+
tier="standard",
|
| 323 |
+
),
|
| 324 |
+
# ========== PREMIUM TIER ==========
|
| 325 |
+
X402Bundle(
|
| 326 |
+
bundle_id="suspicious-transfers",
|
| 327 |
+
name="Suspicious Transfers Scanner",
|
| 328 |
+
description="Cross-chain anomaly detection with RugMunch intelligence",
|
| 329 |
+
category="security",
|
| 330 |
+
endpoints=[
|
| 331 |
+
{"method": "POST", "path": "/scan/transfers", "description": "Scan transfers"},
|
| 332 |
+
{"method": "GET", "path": "/scan/results", "description": "Get results"},
|
| 333 |
+
{"method": "POST", "path": "/scan/anomaly", "description": "Anomaly detection"},
|
| 334 |
+
],
|
| 335 |
+
price_usd=0.15,
|
| 336 |
+
tier="premium",
|
| 337 |
+
is_premium=True,
|
| 338 |
+
),
|
| 339 |
+
X402Bundle(
|
| 340 |
+
bundle_id="wallet-labeler",
|
| 341 |
+
name="Wallet Labeler",
|
| 342 |
+
description="Behavioral reputation engine (Arkham+Nansen+Alchemy)",
|
| 343 |
+
category="security",
|
| 344 |
+
endpoints=[
|
| 345 |
+
{"method": "POST", "path": "/label/wallet", "description": "Label wallet"},
|
| 346 |
+
{"method": "GET", "path": "/label/history", "description": "Label history"},
|
| 347 |
+
{
|
| 348 |
+
"method": "POST",
|
| 349 |
+
"path": "/label/cluster",
|
| 350 |
+
"description": "Cluster detection",
|
| 351 |
+
},
|
| 352 |
+
],
|
| 353 |
+
price_usd=0.20,
|
| 354 |
+
tier="premium",
|
| 355 |
+
is_premium=True,
|
| 356 |
+
),
|
| 357 |
+
X402Bundle(
|
| 358 |
+
bundle_id="memory-bank",
|
| 359 |
+
name="Memory Bank",
|
| 360 |
+
description="Unified agent knowledge store with GCS export",
|
| 361 |
+
category="ai",
|
| 362 |
+
endpoints=[
|
| 363 |
+
{"method": "POST", "path": "/memory/store", "description": "Store knowledge"},
|
| 364 |
+
{"method": "GET", "path": "/memory/query", "description": "Query knowledge"},
|
| 365 |
+
{"method": "POST", "path": "/memory/export", "description": "Export to GCS"},
|
| 366 |
+
],
|
| 367 |
+
price_usd=0.12,
|
| 368 |
+
tier="premium",
|
| 369 |
+
is_premium=True,
|
| 370 |
+
),
|
| 371 |
+
X402Bundle(
|
| 372 |
+
bundle_id="deep-contract-audit",
|
| 373 |
+
name="Deep Contract Audit",
|
| 374 |
+
description="Smart contract audit, honeypot detection, hidden mint functions",
|
| 375 |
+
category="security",
|
| 376 |
+
endpoints=[
|
| 377 |
+
{
|
| 378 |
+
"method": "POST",
|
| 379 |
+
"path": "/audit/contract",
|
| 380 |
+
"description": "Full contract audit",
|
| 381 |
+
},
|
| 382 |
+
{"method": "POST", "path": "/audit/honeypot", "description": "Honeypot check"},
|
| 383 |
+
{
|
| 384 |
+
"method": "POST",
|
| 385 |
+
"path": "/audit/proxy",
|
| 386 |
+
"description": "Proxy pattern check",
|
| 387 |
+
},
|
| 388 |
+
],
|
| 389 |
+
price_usd=0.05,
|
| 390 |
+
tier="premium",
|
| 391 |
+
is_premium=True,
|
| 392 |
+
),
|
| 393 |
+
X402Bundle(
|
| 394 |
+
bundle_id="token-forensics",
|
| 395 |
+
name="Token Forensics",
|
| 396 |
+
description="Deep forensics from DexScreener, GeckoTerminal, CoinGecko",
|
| 397 |
+
category="forensics",
|
| 398 |
+
endpoints=[
|
| 399 |
+
{
|
| 400 |
+
"method": "POST",
|
| 401 |
+
"path": "/forensics/token",
|
| 402 |
+
"description": "Token forensics",
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"method": "POST",
|
| 406 |
+
"path": "/forensics/report",
|
| 407 |
+
"description": "Generate report",
|
| 408 |
+
},
|
| 409 |
+
],
|
| 410 |
+
price_usd=0.10,
|
| 411 |
+
tier="premium",
|
| 412 |
+
is_premium=True,
|
| 413 |
+
),
|
| 414 |
+
X402Bundle(
|
| 415 |
+
bundle_id="whale-decoder",
|
| 416 |
+
name="Whale Decoder",
|
| 417 |
+
description="Advanced whale wallet analysis across chains",
|
| 418 |
+
category="wallet",
|
| 419 |
+
endpoints=[
|
| 420 |
+
{
|
| 421 |
+
"method": "POST",
|
| 422 |
+
"path": "/whale/decode",
|
| 423 |
+
"description": "Decode whale strategy",
|
| 424 |
+
},
|
| 425 |
+
{"method": "GET", "path": "/whale/positions", "description": "Track positions"},
|
| 426 |
+
],
|
| 427 |
+
price_usd=0.15,
|
| 428 |
+
tier="premium",
|
| 429 |
+
is_premium=True,
|
| 430 |
+
),
|
| 431 |
+
X402Bundle(
|
| 432 |
+
bundle_id="darkroom-security",
|
| 433 |
+
name="Darkroom Security Suite",
|
| 434 |
+
description="Advanced security analysis, threat detection, forensics",
|
| 435 |
+
category="security",
|
| 436 |
+
endpoints=[
|
| 437 |
+
{"method": "POST", "path": "/security/scan", "description": "Security scan"},
|
| 438 |
+
{
|
| 439 |
+
"method": "POST",
|
| 440 |
+
"path": "/security/threats",
|
| 441 |
+
"description": "Threat database",
|
| 442 |
+
},
|
| 443 |
+
{
|
| 444 |
+
"method": "POST",
|
| 445 |
+
"path": "/security/forensics",
|
| 446 |
+
"description": "Forensic analysis",
|
| 447 |
+
},
|
| 448 |
+
],
|
| 449 |
+
price_usd=0.20,
|
| 450 |
+
tier="premium",
|
| 451 |
+
is_premium=True,
|
| 452 |
+
),
|
| 453 |
+
X402Bundle(
|
| 454 |
+
bundle_id="solana-mcp",
|
| 455 |
+
name="Solana MCP Server",
|
| 456 |
+
description="Native Solana blockchain queries via MCP protocol",
|
| 457 |
+
category="mcp",
|
| 458 |
+
endpoints=[
|
| 459 |
+
{
|
| 460 |
+
"method": "POST",
|
| 461 |
+
"path": "/mcp/solana/balance",
|
| 462 |
+
"description": "Get SOL balance",
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"method": "POST",
|
| 466 |
+
"path": "/mcp/solana/transaction",
|
| 467 |
+
"description": "Get transaction",
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"method": "POST",
|
| 471 |
+
"path": "/mcp/solana/tokens",
|
| 472 |
+
"description": "Get token accounts",
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"method": "POST",
|
| 476 |
+
"path": "/mcp/solana/simulate",
|
| 477 |
+
"description": "Simulate transaction",
|
| 478 |
+
},
|
| 479 |
+
],
|
| 480 |
+
price_usd=0.08,
|
| 481 |
+
tier="premium",
|
| 482 |
+
is_premium=True,
|
| 483 |
+
),
|
| 484 |
+
X402Bundle(
|
| 485 |
+
bundle_id="ethereum-mcp",
|
| 486 |
+
name="Ethereum MCP Server",
|
| 487 |
+
description="Native Ethereum blockchain queries via MCP protocol",
|
| 488 |
+
category="mcp",
|
| 489 |
+
endpoints=[
|
| 490 |
+
{
|
| 491 |
+
"method": "POST",
|
| 492 |
+
"path": "/mcp/eth/balance",
|
| 493 |
+
"description": "Get ETH balance",
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"method": "POST",
|
| 497 |
+
"path": "/mcp/eth/transaction",
|
| 498 |
+
"description": "Get transaction",
|
| 499 |
+
},
|
| 500 |
+
{"method": "POST", "path": "/mcp/eth/call", "description": "Call contract"},
|
| 501 |
+
{"method": "POST", "path": "/mcp/eth/logs", "description": "Get event logs"},
|
| 502 |
+
{"method": "POST", "path": "/mcp/eth/ens", "description": "Resolve ENS"},
|
| 503 |
+
],
|
| 504 |
+
price_usd=0.08,
|
| 505 |
+
tier="premium",
|
| 506 |
+
is_premium=True,
|
| 507 |
+
),
|
| 508 |
+
]
|
| 509 |
+
|
| 510 |
+
def list_bundles(self, tier: str | None = None) -> list[X402Bundle]:
|
| 511 |
+
"""List all or filtered bundles."""
|
| 512 |
+
if tier:
|
| 513 |
+
return [b for b in self._bundles if b.tier == tier]
|
| 514 |
+
return self._bundles
|
| 515 |
+
|
| 516 |
+
def get_bundle(self, identifier: str) -> X402Bundle | None:
|
| 517 |
+
return self._index.get(identifier.lower())
|
| 518 |
+
|
| 519 |
+
@property
|
| 520 |
+
def free_bundles(self) -> list[X402Bundle]:
|
| 521 |
+
return [b for b in self._bundles if b.tier == "free"]
|
| 522 |
+
|
| 523 |
+
@property
|
| 524 |
+
def trial_bundles(self) -> list[X402Bundle]:
|
| 525 |
+
return [b for b in self._bundles if b.tier == "trial"]
|
| 526 |
+
|
| 527 |
+
@property
|
| 528 |
+
def premium_bundles(self) -> list[X402Bundle]:
|
| 529 |
+
return [b for b in self._bundles if b.is_premium]
|
| 530 |
+
|
| 531 |
+
@property
|
| 532 |
+
def total_endpoints(self) -> int:
|
| 533 |
+
return sum(len(b.endpoints) for b in self._bundles)
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
# ============================================================
|
| 537 |
+
# MAIN X402 SERVER
|
| 538 |
+
# ============================================================
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
class X402MCPServer:
|
| 542 |
+
"""Complete x402 MCP server v5.0."""
|
| 543 |
+
|
| 544 |
+
def __init__(self):
|
| 545 |
+
self._tool_manager = X402ToolManager()
|
| 546 |
+
self._facilitator = MultiChainFacilitator()
|
| 547 |
+
self._ai_guard = AIGuardWrapper()
|
| 548 |
+
self._app = None
|
| 549 |
+
|
| 550 |
+
b = self._tool_manager
|
| 551 |
+
logger.info("🎉 x402 MCP v5.0 initialized")
|
| 552 |
+
logger.info(f" Bundles: {len(b.list_bundles())}")
|
| 553 |
+
logger.info(
|
| 554 |
+
f" Free: {len(b.free_bundles)} | Trial: {len(b.trial_bundles)} | Premium: {len(b.premium_bundles)}"
|
| 555 |
+
)
|
| 556 |
+
logger.info(f" Endpoints: {b.total_endpoints}")
|
| 557 |
+
logger.info(f" Revenue/call: ${sum(b.price_usd for b in b.list_bundles()):.2f}")
|
| 558 |
+
logger.info(f" AI Guard: {'✅ Active' if self._ai_guard.active else '❌ Inactive'}")
|
| 559 |
+
|
| 560 |
+
def streamable_http_app(self) -> FastAPI:
|
| 561 |
+
if self._app is not None:
|
| 562 |
+
return self._app
|
| 563 |
+
|
| 564 |
+
app = FastAPI(
|
| 565 |
+
title="RugMunch Intelligence x402 Gateway v5.0",
|
| 566 |
+
description="Complete crypto security & intelligence x402 gateway with free/trial/premium tiers",
|
| 567 |
+
version="5.0.0",
|
| 568 |
+
docs_url="/",
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
self._add_system_endpoints(app)
|
| 572 |
+
self._add_discovery_endpoints(app)
|
| 573 |
+
self._add_middleware(app)
|
| 574 |
+
|
| 575 |
+
self._app = app
|
| 576 |
+
return app
|
| 577 |
+
|
| 578 |
+
def _add_system_endpoints(self, app: FastAPI):
|
| 579 |
+
"""Health, status, version endpoints."""
|
| 580 |
+
|
| 581 |
+
@app.get("/health")
|
| 582 |
+
async def health():
|
| 583 |
+
b = self._tool_manager
|
| 584 |
+
return JSONResponse(
|
| 585 |
+
{
|
| 586 |
+
"status": "healthy",
|
| 587 |
+
"version": "5.0.0",
|
| 588 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 589 |
+
"statistics": {
|
| 590 |
+
"bundles": len(b.list_bundles()),
|
| 591 |
+
"free": len(b.free_bundles),
|
| 592 |
+
"trial": len(b.trial_bundles),
|
| 593 |
+
"standard": len([x for x in b.list_bundles() if x.tier == "standard"]),
|
| 594 |
+
"premium": len(b.premium_bundles),
|
| 595 |
+
"endpoints": b.total_endpoints,
|
| 596 |
+
},
|
| 597 |
+
"ai_guard": self._ai_guard.active,
|
| 598 |
+
"chains": ["base", "solana"],
|
| 599 |
+
"facilitator": {
|
| 600 |
+
"base_wallet": self._facilitator.base_config["wallet_address"][:20] + "...",
|
| 601 |
+
"solana_wallet": self._facilitator.solana_config["wallet_address"][:20] + "...",
|
| 602 |
+
},
|
| 603 |
+
}
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
@app.get("/version")
|
| 607 |
+
async def version():
|
| 608 |
+
return JSONResponse(
|
| 609 |
+
{
|
| 610 |
+
"version": "5.0.0",
|
| 611 |
+
"name": "RugMunch Intelligence x402 Gateway",
|
| 612 |
+
"chains": ["base", "solana"],
|
| 613 |
+
"tiers": ["free", "trial", "standard", "premium"],
|
| 614 |
+
}
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
def _add_discovery_endpoints(self, app: FastAPI):
|
| 618 |
+
"""x402 discovery and bundle listing."""
|
| 619 |
+
|
| 620 |
+
@app.get("/.well-known/x402")
|
| 621 |
+
async def discovery():
|
| 622 |
+
b = self._tool_manager
|
| 623 |
+
return JSONResponse(
|
| 624 |
+
{
|
| 625 |
+
"version": "5.0.0",
|
| 626 |
+
"name": "RugMunch Intelligence x402 Gateway",
|
| 627 |
+
"description": "Complete crypto security & intelligence with free/trial/premium tiers",
|
| 628 |
+
"facilitators": {
|
| 629 |
+
"base": self._facilitator.base_config,
|
| 630 |
+
"solana": self._facilitator.solana_config,
|
| 631 |
+
"third_party": self._facilitator.third_party,
|
| 632 |
+
},
|
| 633 |
+
"bundles": [bundle.dict() for bundle in b.list_bundles()],
|
| 634 |
+
"tiers": {
|
| 635 |
+
"free": [bundle.dict() for bundle in b.free_bundles],
|
| 636 |
+
"trial": [bundle.dict() for bundle in b.trial_bundles],
|
| 637 |
+
"premium": [bundle.dict() for bundle in b.premium_bundles],
|
| 638 |
+
},
|
| 639 |
+
"statistics": {
|
| 640 |
+
"total_bundles": len(b.list_bundles()),
|
| 641 |
+
"total_endpoints": b.total_endpoints,
|
| 642 |
+
"free_count": len(b.free_bundles),
|
| 643 |
+
"trial_count": len(b.trial_bundles),
|
| 644 |
+
"premium_count": len(b.premium_bundles),
|
| 645 |
+
"max_revenue_usd": f"${sum(x.price_usd for x in b.list_bundles()):.2f}",
|
| 646 |
+
},
|
| 647 |
+
"mcp_servers": {
|
| 648 |
+
"solana": "/mcp/solana",
|
| 649 |
+
"ethereum": "/mcp/eth",
|
| 650 |
+
},
|
| 651 |
+
"documentation": "https://docs.rugmunch.io/x402-v5",
|
| 652 |
+
"support": "support@rugmunch.io",
|
| 653 |
+
}
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
@app.get("/v1/bundles")
|
| 657 |
+
async def list_bundles(tier: str | None = None):
|
| 658 |
+
bundles = self._tool_manager.list_bundles(tier)
|
| 659 |
+
return JSONResponse(
|
| 660 |
+
{
|
| 661 |
+
"success": True,
|
| 662 |
+
"count": len(bundles),
|
| 663 |
+
"tier_filter": tier,
|
| 664 |
+
"bundles": [b.dict() for b in bundles],
|
| 665 |
+
}
|
| 666 |
+
)
|
| 667 |
+
|
| 668 |
+
@app.get("/v1/bundles/{bundle_id}")
|
| 669 |
+
async def get_bundle(bundle_id: str):
|
| 670 |
+
bundle = self._tool_manager.get_bundle(bundle_id)
|
| 671 |
+
if not bundle:
|
| 672 |
+
raise HTTPException(status_code=404, detail="Bundle not found")
|
| 673 |
+
|
| 674 |
+
return JSONResponse(
|
| 675 |
+
{
|
| 676 |
+
"success": True,
|
| 677 |
+
"bundle": bundle.dict(),
|
| 678 |
+
"payment_requirements": {
|
| 679 |
+
"base": self._get_payment_req(bundle, "base"),
|
| 680 |
+
"solana": self._get_payment_req(bundle, "solana"),
|
| 681 |
+
},
|
| 682 |
+
}
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
def _add_middleware(self, app: FastAPI):
|
| 686 |
+
"""AI Guard + x402 payment middleware."""
|
| 687 |
+
|
| 688 |
+
@app.middleware("http")
|
| 689 |
+
async def security_and_payment_middleware(request: Request, call_next):
|
| 690 |
+
path = request.url.path
|
| 691 |
+
|
| 692 |
+
# Skip free endpoints
|
| 693 |
+
if any(
|
| 694 |
+
path.startswith(p) for p in ["/health", "/version", "/.well-known", "/docs", "/openapi", "/v1/bundles"]
|
| 695 |
+
):
|
| 696 |
+
return await call_next(request)
|
| 697 |
+
|
| 698 |
+
# AI Guard check
|
| 699 |
+
if self._ai_guard.active:
|
| 700 |
+
is_safe, reason = await self._ai_guard.check_request(request)
|
| 701 |
+
if not is_safe:
|
| 702 |
+
return JSONResponse(
|
| 703 |
+
{
|
| 704 |
+
"error": "Request blocked by AI Guard",
|
| 705 |
+
"code": "AI_GUARD_BLOCKED",
|
| 706 |
+
"reason": reason,
|
| 707 |
+
},
|
| 708 |
+
status_code=403,
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
# x402 payment check for premium bundles
|
| 712 |
+
# Extract bundle from path
|
| 713 |
+
path_parts = path.split("/")
|
| 714 |
+
if len(path_parts) >= 2:
|
| 715 |
+
bundle_id = (
|
| 716 |
+
path_parts[1]
|
| 717 |
+
if not path_parts[1].startswith("v1")
|
| 718 |
+
else path_parts[2]
|
| 719 |
+
if len(path_parts) > 2
|
| 720 |
+
else None
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
if bundle_id:
|
| 724 |
+
bundle = self._tool_manager.get_bundle(bundle_id)
|
| 725 |
+
if bundle and bundle.requires_payment and bundle.price_usd > 0:
|
| 726 |
+
x402_header = request.headers.get("X-Payment")
|
| 727 |
+
solana_payment = request.headers.get("X-Solana-Payment")
|
| 728 |
+
|
| 729 |
+
if not x402_header and not solana_payment:
|
| 730 |
+
return JSONResponse(
|
| 731 |
+
content={
|
| 732 |
+
"error": "Payment required",
|
| 733 |
+
"code": "PAYMENT_REQUIRED",
|
| 734 |
+
"bundle": bundle_id,
|
| 735 |
+
"bundle_name": bundle.name,
|
| 736 |
+
"price_usd": bundle.price_usd,
|
| 737 |
+
"payment_options": {
|
| 738 |
+
"base": self._get_payment_req(bundle, "base"),
|
| 739 |
+
"solana": self._get_payment_req(bundle, "solana"),
|
| 740 |
+
},
|
| 741 |
+
"instructions": "Include X-Payment (Base) or X-Solana-Payment (Solana) header with transaction proof",
|
| 742 |
+
},
|
| 743 |
+
status_code=402,
|
| 744 |
+
headers={
|
| 745 |
+
"X-Payment-Required": "true",
|
| 746 |
+
"X-Payment-Address-Base": self._facilitator.base_config["wallet_address"],
|
| 747 |
+
"X-Payment-Address-Solana": self._facilitator.solana_config["wallet_address"],
|
| 748 |
+
"X-Bundle-Name": bundle.name,
|
| 749 |
+
"X-Price-USD": str(bundle.price_usd),
|
| 750 |
+
},
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
return await call_next(request)
|
| 754 |
+
|
| 755 |
+
def _get_payment_req(self, bundle: X402Bundle, chain: str) -> dict[str, Any]:
|
| 756 |
+
facilitator = self._facilitator.base_config if chain == "base" else self._facilitator.solana_config
|
| 757 |
+
|
| 758 |
+
if chain == "solana":
|
| 759 |
+
amount_atomic = int(bundle.price_usd * 10**6)
|
| 760 |
+
symbol = "USDC"
|
| 761 |
+
decimals = 6
|
| 762 |
+
else:
|
| 763 |
+
amount_atomic = int(bundle.price_usd / 3000 * 10**18)
|
| 764 |
+
symbol = "ETH"
|
| 765 |
+
decimals = 18
|
| 766 |
+
|
| 767 |
+
return {
|
| 768 |
+
"chain": chain.upper(),
|
| 769 |
+
"network": facilitator["network"],
|
| 770 |
+
"amount_usd": bundle.price_usd,
|
| 771 |
+
"amount_atomic": amount_atomic,
|
| 772 |
+
"symbol": symbol,
|
| 773 |
+
"decimals": decimals,
|
| 774 |
+
"recipient_address": facilitator["wallet_address"],
|
| 775 |
+
"reference": f"bundle-{bundle.bundle_id}",
|
| 776 |
+
"valid_until": (datetime.utcnow() + timedelta(hours=1)).isoformat(),
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
# Create global instance
|
| 781 |
+
mcp = X402MCPServer()
|
| 782 |
+
|
| 783 |
+
if __name__ == "__main__":
|
| 784 |
+
app = mcp.streamable_http_app()
|
| 785 |
+
uvicorn.run(app, host="0.0.0.0", port=8001)
|
backend/main.py
CHANGED
|
@@ -213,12 +213,14 @@ def _try_mount_v1_routers() -> int:
|
|
| 213 |
"app.api.v1.public.token",
|
| 214 |
"app.api.v1.public.scanner",
|
| 215 |
"app.api.v1.rag.search",
|
| 216 |
-
|
| 217 |
"app.api.v1.admin.alerts_webhook",
|
| 218 |
"app.api.v1.catalog",
|
|
|
|
| 219 |
"app.domain.news",
|
| 220 |
"app.domain.news.admin_router",
|
| 221 |
"app.domain.reports",
|
|
|
|
| 222 |
]
|
| 223 |
|
| 224 |
for module_path in v1_modules:
|
|
|
|
| 213 |
"app.api.v1.public.token",
|
| 214 |
"app.api.v1.public.scanner",
|
| 215 |
"app.api.v1.rag.search",
|
| 216 |
+
# x402 moved to app.domain.x402 (T34 v2) — old payments.py removed
|
| 217 |
"app.api.v1.admin.alerts_webhook",
|
| 218 |
"app.api.v1.catalog",
|
| 219 |
+
"app.api.v1.mcp",
|
| 220 |
"app.domain.news",
|
| 221 |
"app.domain.news.admin_router",
|
| 222 |
"app.domain.reports",
|
| 223 |
+
"app.domain.x402",
|
| 224 |
]
|
| 225 |
|
| 226 |
for module_path in v1_modules:
|