merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""HTTP transport layer. Routes are thin: parse → call domain service → return."""
|
||||
34
app/api/deps.py
Normal file
34
app/api/deps.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Shared FastAPI dependencies.
|
||||
|
||||
Use these in route signatures to inject cross-cutting concerns:
|
||||
from app.api.deps import get_redis, get_current_user, get_settings
|
||||
|
||||
Actual implementations live in `app/core/`. This module is a re-export
|
||||
facade so route authors don't need to know which core module owns what.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-exports — actual implementations come from app/core/.
|
||||
# Core modules are populated by the parallel DeepSeek tasks (DS-1..DS-10).
|
||||
# Until then, these imports will fail; routes should not depend on them yet.
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
except ImportError:
|
||||
get_redis = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.db import get_db
|
||||
except ImportError:
|
||||
get_db = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.auth import get_current_user, get_optional_user
|
||||
except ImportError:
|
||||
get_current_user = None # type: ignore[assignment]
|
||||
get_optional_user = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except ImportError:
|
||||
pass # fallback until core.config lands
|
||||
73
app/api/v1/__init__.py
Normal file
73
app/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""V1 API router aggregator.
|
||||
|
||||
The strangle: new v1 routes are added here as domains migrate. The legacy
|
||||
main.py still mounts all old routes; we ADD new v1 routes on top so they
|
||||
co-exist until cutover.
|
||||
|
||||
To add a new domain:
|
||||
1. Create app/api/v1/<group>/<domain>.py with APIRouter
|
||||
2. Import and append it to `api_v1_router` below
|
||||
3. Mount the route prefix in the domain's __init__.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Aggregator list — populated as domains migrate.
|
||||
# Each entry is an APIRouter from app/api/v1/<group>/<domain>.py.
|
||||
api_v1_router: list[APIRouter] = []
|
||||
|
||||
# Aggregator router — single mount point for v1.
|
||||
# When domains migrate, replace this with a real aggregator:
|
||||
# from app.api.v1.public import router as public_router
|
||||
# api_v1_router.append(public_router)
|
||||
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
||||
|
||||
|
||||
# ── Migrated domains ───────────────────────────────────────────────────
|
||||
# Each migrated domain is imported here. The router exposes endpoints
|
||||
# at /api/v1/<domain>/* (path defined per-router).
|
||||
#
|
||||
# During strangelfig, the LEGACY /api/v1/<domain>/* endpoints remain
|
||||
# mounted in main.py. The new v1 router is mounted at the same path
|
||||
# (FastAPI handles prefix-based routing) — first match wins, so the
|
||||
# legacy stays until we explicitly remove it.
|
||||
|
||||
from app.api.v1.auth.alerts import router as alerts_router # noqa: E402
|
||||
|
||||
api_v1_router.append(alerts_router)
|
||||
|
||||
from app.api.v1.public.wallet import router as wallet_router # noqa: E402
|
||||
|
||||
api_v1_router.append(wallet_router)
|
||||
|
||||
from app.api.v1.public.token import router as token_router # noqa: E402
|
||||
|
||||
api_v1_router.append(token_router)
|
||||
|
||||
from app.api.v1.public.scanner import router as scanner_router # noqa: E402
|
||||
|
||||
api_v1_router.append(scanner_router)
|
||||
|
||||
# x402 moved to app.domain.x402 (T34 v2)
|
||||
# Old app/api/v1/x402/payments.py removed to avoid model conflicts
|
||||
|
||||
from app.api.v1.rag.search import router as rag_v2_router # noqa: E402
|
||||
|
||||
api_v1_router.append(rag_v2_router)
|
||||
|
||||
from app.api.v1.admin.alerts_webhook import router as admin_alerts_webhook_router # noqa: E402
|
||||
|
||||
api_v1_router.append(admin_alerts_webhook_router)
|
||||
|
||||
from app.api.v1.catalog import router as catalog_router # noqa: E402
|
||||
|
||||
api_v1_router.append(catalog_router)
|
||||
|
||||
|
||||
def build_v1_router() -> APIRouter:
|
||||
"""Construct the v1 aggregator with all migrated routes mounted."""
|
||||
aggregated = APIRouter(prefix="/api/v1")
|
||||
for r in api_v1_router:
|
||||
aggregated.include_router(r)
|
||||
return aggregated
|
||||
4
app/api/v1/admin/__init__.py
Normal file
4
app/api/v1/admin/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Admin routes — admin role required.
|
||||
|
||||
Target: user management, system config, ops, bulletin moderation.
|
||||
"""
|
||||
32
app/api/v1/admin/alerts_webhook.py
Normal file
32
app/api/v1/admin/alerts_webhook.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Admin alerts webhook — /api/v1/admin/alerts/webhook.
|
||||
|
||||
Stub endpoint for receiving alert webhooks from external sources
|
||||
(monitoring, observability platforms).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["admin"])
|
||||
|
||||
|
||||
class AlertWebhookPayload(BaseModel):
|
||||
"""Generic webhook payload from external alert sources."""
|
||||
|
||||
source: str # "prometheus" | "grafana" | "sentry" | "custom"
|
||||
severity: str # "info" | "warning" | "critical"
|
||||
title: str
|
||||
description: str | None = None
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def receive_alert_webhook(payload: AlertWebhookPayload) -> dict[str, Any]:
|
||||
"""Receive an alert webhook from external monitoring."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Alert webhook ingestion not yet implemented — pending T08 GlitchTip wiring",
|
||||
)
|
||||
60
app/api/v1/admin/glitchtip_test.py
Normal file
60
app/api/v1/admin/glitchtip_test.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""T07 GlitchTip test endpoint.
|
||||
|
||||
POST /api/v1/_test/glitchtip
|
||||
{"type": "error", "message": "test error"}
|
||||
POST /api/v1/_test/exception
|
||||
→ triggers a real exception, captured by GlitchTip
|
||||
POST /api/v1/_test/message
|
||||
→ captures an info-level message
|
||||
|
||||
Used for:
|
||||
- Verifying the GlitchTip pipeline works
|
||||
- Smoke testing after deploys
|
||||
- Demonstrating the secret-scrubbing before_send hook
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/_test", tags=["test"])
|
||||
|
||||
|
||||
class GlitchtipTestRequest(BaseModel):
|
||||
type: str = "error" # error | exception | message
|
||||
message: str = "test event from RMI"
|
||||
secret: str | None = None # should be REDACTED in Sentry
|
||||
|
||||
|
||||
@router.post("/glitchtip")
|
||||
async def test_glitchtip(req: GlitchtipTestRequest) -> dict:
|
||||
"""Trigger a test event. Tests the secret-scrubbing before_send hook."""
|
||||
if req.type == "exception":
|
||||
try:
|
||||
raise ValueError(req.message)
|
||||
except Exception as e:
|
||||
try:
|
||||
from app.core.observability import capture_exception
|
||||
capture_exception(e, secret=req.secret, route="/api/v1/_test/glitchtip")
|
||||
except ImportError:
|
||||
log.exception("test_exception_no_sentry")
|
||||
return {"captured": "exception", "message": req.message}
|
||||
if req.type == "message":
|
||||
try:
|
||||
from app.core.observability import capture_message
|
||||
capture_message(req.message, level="warning", secret=req.secret)
|
||||
except ImportError:
|
||||
log.warning(f"test_message_no_sentry: {req.message}")
|
||||
return {"captured": "message", "message": req.message}
|
||||
# default: error log + capture
|
||||
log.error(f"test_error: {req.message} (secret={req.secret})")
|
||||
try:
|
||||
from app.core.observability import capture_message
|
||||
capture_message(req.message, level="error", secret=req.secret)
|
||||
except ImportError:
|
||||
pass
|
||||
return {"captured": "error", "message": req.message, "secret_redacted_in_sentry": True}
|
||||
4
app/api/v1/auth/__init__.py
Normal file
4
app/api/v1/auth/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Authenticated routes — JWT required.
|
||||
|
||||
Target: portfolio, alerts, intel feeds, profile, settings.
|
||||
"""
|
||||
65
app/api/v1/auth/alerts.py
Normal file
65
app/api/v1/auth/alerts.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Auth alerts router — /api/v1/alerts/*.
|
||||
|
||||
Stub implementation for the alerts domain. Real implementations
|
||||
will wire up to user-configured alert rules and notification channels
|
||||
(email, Telegram, webhook). For now, returns 501 Not Implemented
|
||||
for actual alert operations, with version metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
class AlertRule(BaseModel):
|
||||
"""Schema for an alert rule (creation/edit)."""
|
||||
|
||||
name: str
|
||||
subject_type: str # "token" | "wallet" | "deployer"
|
||||
subject_id: str
|
||||
trigger: str # "risk_score_above" | "deployer_rug" | "news_mention"
|
||||
threshold: float | None = None
|
||||
channels: list[str] = [] # ["email", "telegram", "webhook"]
|
||||
|
||||
|
||||
class AlertList(BaseModel):
|
||||
"""Response for GET /api/v1/alerts."""
|
||||
|
||||
count: int
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@router.get("", response_model=AlertList)
|
||||
async def list_alerts() -> AlertList:
|
||||
"""List all configured alert rules for the authenticated user.
|
||||
|
||||
TODO: wire up to Postgres once auth context is established.
|
||||
Returns empty list as a stub so the factory can mount successfully.
|
||||
"""
|
||||
return AlertList(count=0, items=[])
|
||||
|
||||
|
||||
@router.post("", status_code=501)
|
||||
async def create_alert(rule: AlertRule) -> dict[str, str]:
|
||||
"""Create a new alert rule.
|
||||
|
||||
Returns 501 until alert persistence is wired up. Stub so the
|
||||
factory mounts this route without crashing.
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Alert persistence not yet implemented — coming in v5.1",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", status_code=501)
|
||||
async def delete_alert(rule_id: str) -> dict[str, str]:
|
||||
"""Delete an alert rule by ID."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Alert persistence not yet implemented — coming in v5.1",
|
||||
)
|
||||
4
app/api/v1/catalog/__init__.py
Normal file
4
app/api/v1/catalog/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Catalog v1 routes — thin HTTP layer."""
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
155
app/api/v1/catalog/router.py
Normal file
155
app/api/v1/catalog/router.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""T27B HTTP routes — CatalogService endpoints.
|
||||
|
||||
Per v4.0 §T27. The thin HTTP layer over app.catalog.service.CatalogService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.catalog.models import Chain
|
||||
from app.catalog.service import get_catalog
|
||||
|
||||
router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"])
|
||||
|
||||
|
||||
# ── Request models ───────────────────────────────────────────────
|
||||
class RagIngestRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1)
|
||||
collection: str = "scam_intel"
|
||||
doc_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RagSearchRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1)
|
||||
collection: str = "scam_intel"
|
||||
top_k: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class ResolveEntityRequest(BaseModel):
|
||||
wallet_id: str
|
||||
max_chains: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
|
||||
class FindRiskyTokensRequest(BaseModel):
|
||||
min_rug_count: int = Field(default=1, ge=1)
|
||||
chain: str | None = None
|
||||
limit: int = Field(default=50, ge=1, le=200)
|
||||
|
||||
|
||||
class AttachRagRequest(BaseModel):
|
||||
chain: str
|
||||
address: str
|
||||
qdrant_point_id: str
|
||||
|
||||
|
||||
# ── Health / introspection ───────────────────────────────────────
|
||||
@router.get("/stats")
|
||||
async def stats() -> dict:
|
||||
"""Catalog stats: which stores are reachable + entity counts."""
|
||||
return await get_catalog().stats()
|
||||
|
||||
|
||||
@router.get("/probe")
|
||||
async def probe() -> dict:
|
||||
"""Probe which stores are reachable from this container."""
|
||||
return await get_catalog().probe_stores()
|
||||
|
||||
|
||||
# ── Token endpoints ─────────────────────────────────────────────
|
||||
@router.get("/tokens/{chain}/{address}")
|
||||
async def get_token(chain: str, address: str) -> dict:
|
||||
"""Get a token by chain+address. Returns full Token model + provenance."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
tok = await get_catalog().get_token(c, address)
|
||||
if not tok:
|
||||
raise HTTPException(404, "token not found")
|
||||
return tok.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/tokens/{chain}/{address}/risk")
|
||||
async def get_token_risk(chain: str, address: str) -> dict:
|
||||
"""Recipe 3 — Real-time risk score. Composes Redis + Postgres + Neo4j."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
return await get_catalog().get_token_risk(c, address)
|
||||
|
||||
|
||||
@router.post("/tokens/risky-by-deployer")
|
||||
async def risky_tokens(req: FindRiskyTokensRequest) -> dict:
|
||||
"""Recipe 1 — Find tokens deployed by wallets with rug history."""
|
||||
chain_enum = None
|
||||
if req.chain:
|
||||
try:
|
||||
chain_enum = Chain(req.chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {req.chain}")
|
||||
tokens = await get_catalog().find_tokens_by_deployer_history(
|
||||
min_rug_count=req.min_rug_count, chain=chain_enum, limit=req.limit
|
||||
)
|
||||
return {
|
||||
"count": len(tokens),
|
||||
"tokens": [t.model_dump(mode="json") for t in tokens],
|
||||
}
|
||||
|
||||
|
||||
# ── Wallet endpoints ─────────────────────────────────────────────
|
||||
@router.get("/wallets/{chain}/{address}")
|
||||
async def get_wallet(chain: str, address: str) -> dict:
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
w = await get_catalog().get_wallet(c, address)
|
||||
if not w:
|
||||
raise HTTPException(404, "wallet not found")
|
||||
return w.model_dump(mode="json")
|
||||
|
||||
|
||||
# ── Entity resolution (Recipe 5) ────────────────────────────────
|
||||
@router.post("/entities/resolve")
|
||||
async def resolve_entity(req: ResolveEntityRequest) -> dict:
|
||||
"""Cross-chain entity resolution via Neo4j Cypher."""
|
||||
return await get_catalog().resolve_entity(req.wallet_id, req.max_chains)
|
||||
|
||||
|
||||
# ── RAG bridge endpoints ────────────────────────────────────────
|
||||
@router.post("/rag/search")
|
||||
async def rag_search(req: RagSearchRequest) -> dict:
|
||||
"""Search the RAG system. Returns ranked hits with RRF scores."""
|
||||
hits = await get_catalog().rag_search(
|
||||
query=req.query, collection=req.collection, top_k=req.top_k
|
||||
)
|
||||
return {"count": len(hits), "hits": hits}
|
||||
|
||||
|
||||
@router.post("/rag/ingest")
|
||||
async def rag_ingest(req: RagIngestRequest) -> dict:
|
||||
"""Ingest content into RAG. Returns qdrant_point_id for cross-store linking."""
|
||||
return await get_catalog().rag_ingest(
|
||||
content=req.content,
|
||||
collection=req.collection,
|
||||
doc_id=req.doc_id,
|
||||
metadata=req.metadata,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tokens/{chain}/{address}/attach-rag")
|
||||
async def attach_rag(chain: str, address: str, req: AttachRagRequest) -> dict:
|
||||
"""Link an existing RAG embedding (Qdrant point) to a Token row."""
|
||||
try:
|
||||
c = Chain(chain)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"unknown chain: {chain}")
|
||||
ok = await get_catalog().attach_rag_to_token(c, address, req.qdrant_point_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "token not found or update failed")
|
||||
return {"ok": True, "chain": chain, "address": address, "rag_embedding_id": req.qdrant_point_id}
|
||||
4
app/api/v1/mcp/__init__.py
Normal file
4
app/api/v1/mcp/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""MCP v1 routes."""
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
164
app/api/v1/mcp/router.py
Normal file
164
app/api/v1/mcp/router.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""T33 MCP Server — HTTP wrapper for SSE transport.
|
||||
|
||||
Per v4.0 §T33. Endpoints:
|
||||
POST /mcp JSON-RPC 2.0 endpoint
|
||||
GET /mcp/tools Tool catalog
|
||||
POST /mcp/call/{tool_id} Direct tool execution (no JSON-RPC)
|
||||
|
||||
The server speaks the Model Context Protocol natively. Claude Desktop
|
||||
and Cursor connect via:
|
||||
{"mcpServers": {"rugmunch": {"url": "https://mcp.rugmunch.io/mcp", "transport": "sse"}}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.mcp.server import (
|
||||
MCP_PROTOCOL_VERSION,
|
||||
MCP_SERVER_VERSION,
|
||||
TOOL_CATALOG,
|
||||
TOOL_DEPRECATED,
|
||||
TOOL_SUCCESSORS,
|
||||
TOOL_VERSIONS,
|
||||
call_tool,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/mcp", tags=["mcp"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JsonRpcRequest(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
method: str
|
||||
params: dict[str, Any] = {}
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
class JsonRpcResponse(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
result: Any | None = None
|
||||
error: dict | None = None
|
||||
id: int | str | None = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def jsonrpc_handler(req: JsonRpcRequest) -> dict:
|
||||
"""JSON-RPC 2.0 endpoint for MCP clients.
|
||||
|
||||
Methods:
|
||||
- initialize → returns server info
|
||||
- tools/list → returns tool catalog
|
||||
- tools/call → dispatches to backend
|
||||
- resources/list → empty
|
||||
- prompts/list → empty
|
||||
"""
|
||||
if req.jsonrpc != "2.0":
|
||||
return {"jsonrpc": "2.0", "error": {"code": -32600, "message": "invalid jsonrpc version"}, "id": req.id}
|
||||
|
||||
if req.method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "rugmunch-intelligence",
|
||||
"version": MCP_SERVER_VERSION,
|
||||
"description": "Crypto intelligence platform — 13+ chains, 8 MCP tools, x402 paid tier",
|
||||
},
|
||||
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
||||
},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"tools": TOOL_CATALOG},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "tools/call":
|
||||
name = req.params.get("name", "")
|
||||
arguments = req.params.get("arguments", {})
|
||||
if not name:
|
||||
return {"jsonrpc": "2.0", "error": {"code": -32602, "message": "tool name required"}, "id": req.id}
|
||||
result = await call_tool(name, arguments)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": json.dumps(result, default=str)[:50000]}],
|
||||
"isError": "error" in result,
|
||||
},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
if req.method == "resources/list":
|
||||
return {"jsonrpc": "2.0", "result": {"resources": []}, "id": req.id}
|
||||
|
||||
if req.method == "prompts/list":
|
||||
return {"jsonrpc": "2.0", "result": {"prompts": []}, "id": req.id}
|
||||
|
||||
if req.method == "notifications/initialized":
|
||||
return {"jsonrpc": "2.0", "result": {}, "id": req.id}
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32601, "message": f"method not found: {req.method}"},
|
||||
"id": req.id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools() -> dict:
|
||||
"""Plain JSON endpoint (for direct integration, no JSON-RPC)."""
|
||||
return {"server": "rugmunch-intelligence", "version": MCP_SERVER_VERSION, "tools": TOOL_CATALOG}
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def server_info() -> dict:
|
||||
"""Server metadata: version, protocol, tool count, capabilities.
|
||||
|
||||
Use this to discover the MCP server's capabilities without listing all tools.
|
||||
Equivalent to MCP initialize handshake.
|
||||
"""
|
||||
return {
|
||||
"server": "rugmunch-intelligence",
|
||||
"server_version": MCP_SERVER_VERSION,
|
||||
"protocol_version": MCP_PROTOCOL_VERSION,
|
||||
"tool_count": len(TOOL_CATALOG),
|
||||
"tools_versioned": sum(1 for t in TOOL_CATALOG if t["name"] in TOOL_VERSIONS),
|
||||
"tools_deprecated": list(TOOL_DEPRECATED),
|
||||
"tools_with_successors": list(TOOL_SUCCESSORS.keys()),
|
||||
"capabilities": ["tools", "resources", "prompts"],
|
||||
"endpoints": {
|
||||
"jsonrpc": "/mcp",
|
||||
"tools_list": "/mcp/tools",
|
||||
"server_info": "/mcp/info",
|
||||
"direct_call": "/mcp/call/{tool_id}",
|
||||
},
|
||||
"auth": {
|
||||
"free_tier_daily": 5,
|
||||
"pro_tier": "x402 micropayment per call",
|
||||
"x402_endpoint": "https://x402.rugmunch.io",
|
||||
},
|
||||
"links": {
|
||||
"homepage": "https://rugmunch.io",
|
||||
"mcp_endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"status": "https://status.rugmunch.io",
|
||||
"docs": "https://github.com/Rug-Munch-Media-LLC/rmi-docs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/call/{tool_id}")
|
||||
async def direct_call(tool_id: str, request: Request) -> dict:
|
||||
"""Direct tool execution (no JSON-RPC). For curl/scripts."""
|
||||
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
||||
arguments = body.get("arguments", body) if isinstance(body, dict) else {}
|
||||
result = await call_tool(tool_id, arguments)
|
||||
return result
|
||||
4
app/api/v1/public/__init__.py
Normal file
4
app/api/v1/public/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Public routes — no authentication required.
|
||||
|
||||
Target: scanner, wallet lookup, token info, pricing, health.
|
||||
"""
|
||||
62
app/api/v1/public/scanner.py
Normal file
62
app/api/v1/public/scanner.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Public scanner endpoints — /api/v1/scanner/*.
|
||||
|
||||
Stub for unauthenticated token scans. Real implementation will
|
||||
trigger the scanner pipeline (honeypot detection, flash loan checks,
|
||||
oracle manipulation analysis).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/scanner", tags=["scanner"])
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
"""Request to scan a token or wallet."""
|
||||
|
||||
chain: str = "ethereum"
|
||||
address: str
|
||||
depth: str = "standard" # "quick" | "standard" | "deep"
|
||||
|
||||
|
||||
class ScanResult(BaseModel):
|
||||
"""Result of a scan."""
|
||||
|
||||
scan_id: str
|
||||
status: str # "queued" | "running" | "completed" | "failed"
|
||||
risk_score: int | None = None
|
||||
risk_tier: str | None = None
|
||||
findings: list[str] = []
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResult)
|
||||
async def scan(req: ScanRequest) -> ScanResult:
|
||||
"""Queue a token/wallet scan."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Scanner pipeline not yet wired — uses app.domain.scanner (T06+)",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/result/{scan_id}", response_model=ScanResult)
|
||||
async def get_scan_result(scan_id: str) -> ScanResult:
|
||||
"""Get the result of a previously queued scan."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Scan result retrieval not yet implemented",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quick")
|
||||
async def quick_scan(
|
||||
chain: str = Query("ethereum"),
|
||||
address: str = Query(...),
|
||||
) -> dict[str, Any]:
|
||||
"""Quick scan (free tier, no persistence)."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Quick scan uses cached shield — see caching_shield module",
|
||||
)
|
||||
65
app/api/v1/public/token.py
Normal file
65
app/api/v1/public/token.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Public token endpoints — /api/v1/token/*.
|
||||
|
||||
Stub for unauthenticated token queries. Real implementation will
|
||||
fetch token metadata, holders, liquidity, and risk score.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/token", tags=["token"])
|
||||
|
||||
|
||||
class TokenSummary(BaseModel):
|
||||
"""Basic token metadata."""
|
||||
|
||||
chain: str
|
||||
address: str
|
||||
name: str | None = None
|
||||
symbol: str | None = None
|
||||
decimals: int | None = None
|
||||
deployed_at: str | None = None
|
||||
deployer: str | None = None
|
||||
|
||||
|
||||
class TokenRisk(BaseModel):
|
||||
"""Token risk assessment."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
risk_score: int
|
||||
risk_tier: str
|
||||
factors: list[str] = []
|
||||
|
||||
|
||||
@router.get("/{address}", response_model=TokenSummary)
|
||||
async def get_token(
|
||||
address: str,
|
||||
chain: str = Query("ethereum"),
|
||||
) -> TokenSummary:
|
||||
"""Fetch basic token metadata."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Token lookup not yet implemented — coming in v5.1",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/risk", response_model=TokenRisk)
|
||||
async def get_token_risk(address: str, chain: str = "ethereum") -> TokenRisk:
|
||||
"""Compute the risk score for a token (uses Bayesian reputation + on-chain checks)."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Token risk uses T01 Bayesian + T02 scanner pipeline — pending wiring",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/holders")
|
||||
async def get_token_holders(address: str, chain: str = "ethereum", top: int = 50) -> dict[str, Any]:
|
||||
"""Return top holders distribution for a token."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Holder distribution not yet implemented — uses Postgres + Neo4j",
|
||||
)
|
||||
57
app/api/v1/public/wallet.py
Normal file
57
app/api/v1/public/wallet.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Public wallet endpoints — /api/v1/wallet/*.
|
||||
|
||||
Stub for unauthenticated wallet queries. Real implementation will
|
||||
resolve wallets, fetch labels, and return balance/history data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/wallet", tags=["wallet"])
|
||||
|
||||
|
||||
class WalletResolveResponse(BaseModel):
|
||||
"""Response for wallet resolution."""
|
||||
|
||||
chain: str
|
||||
address: str
|
||||
labels: list[str] = []
|
||||
entity: str | None = None
|
||||
balance_usd: float | None = None
|
||||
tx_count: int | None = None
|
||||
|
||||
|
||||
@router.get("/{address}", response_model=WalletResolveResponse)
|
||||
async def resolve_wallet(
|
||||
address: str,
|
||||
chain: str = Query("ethereum", description="Blockchain (ethereum, solana, base, etc.)"),
|
||||
) -> WalletResolveResponse:
|
||||
"""Resolve a wallet address to its labels + summary.
|
||||
|
||||
Returns 501 stub until label resolution is wired up.
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Wallet resolution not yet implemented — coming in v5.1",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/labels", response_model=list[dict[str, Any]])
|
||||
async def get_wallet_labels(address: str, chain: str = "ethereum") -> list[dict[str, Any]]:
|
||||
"""Return labels for a wallet from all federated sources."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Federated labels API pending — see T11",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{address}/history")
|
||||
async def get_wallet_history(address: str, chain: str = "ethereum") -> dict[str, Any]:
|
||||
"""Return transaction history summary for a wallet."""
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Wallet history pending — uses Neo4j + Postgres in v5.1",
|
||||
)
|
||||
81
app/api/v1/rag/search.py
Normal file
81
app/api/v1/rag/search.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""V1 RAG route — thin HTTP layer over app.rag.
|
||||
|
||||
The RAG system is the most coupled module (14 legacy files). This
|
||||
facade exposes the most-used operations: search, ingest, feedback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.rag import (
|
||||
FeedbackRecord,
|
||||
IngestRequest,
|
||||
IngestResult,
|
||||
RAGService,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
)
|
||||
from app.rag.engine import bulk_ingest as engine_bulk_ingest
|
||||
from app.rag.engine import get_stats as engine_get_stats
|
||||
|
||||
router = APIRouter(prefix="/api/v1/rag/v2", tags=["rag"])
|
||||
|
||||
|
||||
def _service() -> RAGService:
|
||||
return RAGService()
|
||||
|
||||
|
||||
class BulkIngestRequest(BaseModel):
|
||||
collection: str = "scam_intel"
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResponse)
|
||||
async def search(
|
||||
req: SearchRequest,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> SearchResponse:
|
||||
"""RAG search. Returns Pydantic response with hits + scores."""
|
||||
return await svc.search(req)
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestResult)
|
||||
async def ingest(
|
||||
req: IngestRequest,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> IngestResult:
|
||||
"""Ingest a document into the RAG system."""
|
||||
return await svc.ingest(req)
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=IngestResult)
|
||||
async def feedback(
|
||||
record: FeedbackRecord,
|
||||
svc: Annotated[RAGService, Depends(_service)],
|
||||
) -> IngestResult:
|
||||
"""Record scanner → RAG feedback. Ingests known scam into known_scams collection."""
|
||||
ok = await svc.record_feedback(record)
|
||||
return IngestResult(
|
||||
doc_id=record.token_address,
|
||||
collection="known_scams",
|
||||
status="ok" if ok else "failed",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats() -> dict:
|
||||
"""Per-collection vector counts + active embedder backend."""
|
||||
return engine_get_stats()
|
||||
|
||||
|
||||
@router.post("/bulk-ingest")
|
||||
async def bulk(req: BulkIngestRequest) -> dict:
|
||||
"""Ingest many items into a collection sequentially (max 500 per call)."""
|
||||
if not req.items:
|
||||
raise HTTPException(status_code=400, detail="items must be non-empty")
|
||||
if len(req.items) > 500:
|
||||
raise HTTPException(status_code=400, detail="bulk limit 500 per call")
|
||||
return await engine_bulk_ingest(items=req.items, collection=req.collection)
|
||||
4
app/api/v1/x402/__init__.py
Normal file
4
app/api/v1/x402/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""x402 paid routes — crypto micropayment gated.
|
||||
|
||||
Target: tools (split from legacy x402_tools.py), tokens, wallets, defi, security.
|
||||
"""
|
||||
4
app/api/ws/__init__.py
Normal file
4
app/api/ws/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""WebSocket endpoints.
|
||||
|
||||
Target: real-time alerts, scanner results, intel feeds.
|
||||
"""
|
||||
Loading…
Add table
Add a link
Reference in a new issue