瀏覽代碼

Initial MCP HTTP server cleanup

Lukas Goldschmidt 1 月之前
當前提交
eee45cf911
共有 20 個文件被更改,包括 1184 次插入0 次删除
  1. 38 0
      .gitignore
  2. 32 0
      PROJECT.md
  3. 63 0
      README.md
  4. 69 0
      cache/__init__.py
  5. 51 0
      config.py
  6. 33 0
      errors.py
  7. 75 0
      indicators/__init__.py
  8. 31 0
      killserver.sh
  9. 227 0
      main.py
  10. 9 0
      mcp_tools.py
  11. 21 0
      providers/__init__.py
  12. 65 0
      providers/binance.py
  13. 87 0
      providers/coingecko.py
  14. 8 0
      requirements.txt
  15. 4 0
      restart.sh
  16. 27 0
      run.sh
  17. 38 0
      server.py
  18. 58 0
      services/__init__.py
  19. 238 0
      tests.py
  20. 10 0
      tests.sh

+ 38 - 0
.gitignore

@@ -0,0 +1,38 @@
+# Ignore Python artifacts
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Virtual environments
+venv/
+ENV/
+env/
+.venv/
+env.bak/
+
+# Node modules (if any)
+node_modules/
+
+# Distribution / build
+/dist/
+build/
+dist/
+*.egg-info/
+
+# Environment and logs
+*.env
+.env*
+*.log
+
+# IDEs and editors
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Misc
+.coverage
+.coverage.*
+.cache/
+*.pytest_cache/

+ 32 - 0
PROJECT.md

@@ -0,0 +1,32 @@
+# PROJECT.md
+
+## Purpose
+
+`crypto-mcp` is a compact MCP server for crypto-related tools.
+The current goal is a clean HTTP JSON-RPC 2.0 endpoint that works well with MCP clients such as mcporter.
+
+## Current interface
+
+- `GET /` → tool discovery JSON
+- `GET /health` → health and cache stats
+- `POST /mcp` → JSON-RPC 2.0 MCP transport
+
+## Tool set
+
+- `get_price`
+- `get_ohlcv`
+- `get_indicator`
+- `get_market_snapshot`
+- `get_top_movers`
+
+## Notes
+
+- No SSE transport.
+- No event stream.
+- No URL-fetching helper tool.
+- Keep the transport small and predictable.
+
+## Verification
+
+- `./tests.sh`
+- `./run.sh`

+ 63 - 0
README.md

@@ -0,0 +1,63 @@
+# Crypto MCP Server
+
+A small MCP-first server for crypto market data and technical indicators.
+
+## Transport
+
+- **HTTP JSON-RPC 2.0** at `POST /mcp`
+- **Discovery** at `GET /`
+- **Health** at `GET /health`
+
+No SSE, no event stream, no extra REST API surface.
+
+## Runtime
+
+```bash
+pip install -r requirements.txt
+./run.sh
+```
+
+Default URL:
+
+```bash
+http://127.0.0.1:8505/mcp
+```
+
+## MCP methods
+
+- `initialize`
+- `tools/list`
+- `tools/call`
+
+## Tools
+
+- `get_price`
+- `get_ohlcv`
+- `get_indicator`
+- `get_market_snapshot`
+- `get_top_movers`
+
+## Tests
+
+```bash
+./tests.sh
+```
+
+## Project layout
+
+```text
+crypto-mcp/
+├── main.py
+├── mcp_tools.py
+├── cache/
+├── indicators/
+├── providers/
+├── services/
+├── config.py
+├── errors.py
+├── run.sh
+├── killserver.sh
+├── restart.sh
+├── tests.py
+└── tests.sh
+```

+ 69 - 0
cache/__init__.py

@@ -0,0 +1,69 @@
+"""TTL-based in-memory cache."""
+
+import threading
+import time
+from typing import Any, Optional
+
+from config import PRICE_TTL, OHLCV_TTL
+
+
+class TTLCache:
+    def __init__(self):
+        self._store: dict[str, tuple[Any, float]] = {}
+        self._lock = threading.Lock()
+
+    def get(self, key: str) -> Optional[Any]:
+        with self._lock:
+            entry = self._store.get(key)
+            if entry is None:
+                return None
+            value, expires_at = entry
+            if time.time() > expires_at:
+                del self._store[key]
+                return None
+            return value
+
+    def set(self, key: str, value: Any, ttl: int) -> None:
+        with self._lock:
+            self._store[key] = (value, time.time() + ttl)
+
+    def delete(self, key: str) -> None:
+        with self._lock:
+            self._store.pop(key, None)
+
+    def stats(self) -> dict:
+        with self._lock:
+            now = time.time()
+            alive = sum(1 for _, (_, exp) in self._store.items() if exp > now)
+            return {"total_keys": len(self._store), "alive_keys": alive}
+
+
+_cache = TTLCache()
+
+
+def cache_key_price(symbol: str) -> str:
+    return f"price:{symbol.upper()}"
+
+
+def cache_key_ohlcv(symbol: str, timeframe: str) -> str:
+    return f"ohlcv:{symbol.upper()}:{timeframe}"
+
+
+def get_cached_price(symbol: str) -> Optional[dict]:
+    return _cache.get(cache_key_price(symbol))
+
+
+def set_cached_price(symbol: str, data: dict) -> None:
+    _cache.set(cache_key_price(symbol), data, ttl=PRICE_TTL)
+
+
+def get_cached_ohlcv(symbol: str, timeframe: str) -> Optional[dict]:
+    return _cache.get(cache_key_ohlcv(symbol, timeframe))
+
+
+def set_cached_ohlcv(symbol: str, timeframe: str, data: dict) -> None:
+    _cache.set(cache_key_ohlcv(symbol, timeframe), data, ttl=OHLCV_TTL.get(timeframe, 60))
+
+
+def get_cache_stats() -> dict:
+    return _cache.stats()

+ 51 - 0
config.py

@@ -0,0 +1,51 @@
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# Cache TTLs (seconds)
+PRICE_TTL = int(os.getenv("PRICE_TTL", 10))
+OHLCV_TTL = {
+    "1m": int(os.getenv("OHLCV_TTL_1M", 30)),
+    "5m": int(os.getenv("OHLCV_TTL_5M", 60)),
+    "1h": int(os.getenv("OHLCV_TTL_1H", 300)),
+    "4h": int(os.getenv("OHLCV_TTL_4H", 600)),
+    "1d": int(os.getenv("OHLCV_TTL_1D", 3600)),
+}
+
+# Providers
+COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
+BINANCE_BASE_URL = "https://api.binance.com/api/v3"
+
+# Symbol normalization map (add more as needed)
+SYMBOL_TO_COINGECKO_ID = {
+    "BTC": "bitcoin",
+    "ETH": "ethereum",
+    "SOL": "solana",
+    "BNB": "binancecoin",
+    "XRP": "ripple",
+    "ADA": "cardano",
+    "DOGE": "dogecoin",
+    "AVAX": "avalanche-2",
+    "DOT": "polkadot",
+    "MATIC": "matic-network",
+    "LINK": "chainlink",
+    "UNI": "uniswap",
+    "LTC": "litecoin",
+    "ATOM": "cosmos",
+    "XLM": "stellar",
+}
+
+# Timeframe → Binance interval mapping
+TIMEFRAME_TO_BINANCE = {
+    "1m": "1m",
+    "5m": "5m",
+    "15m": "15m",
+    "1h": "1h",
+    "4h": "4h",
+    "1d": "1d",
+}
+
+# Default OHLCV limit
+DEFAULT_OHLCV_LIMIT = 100
+MAX_OHLCV_LIMIT = 500

+ 33 - 0
errors.py

@@ -0,0 +1,33 @@
+"""Standardized error types for the Crypto MCP server."""
+
+
+class CryptoMCPError(Exception):
+    """Base error."""
+    code: str = "INTERNAL_ERROR"
+
+    def to_dict(self) -> dict:
+        return {"error": self.code, "detail": str(self)}
+
+
+class SymbolNotFoundError(CryptoMCPError):
+    code = "SYMBOL_NOT_FOUND"
+
+
+class ProviderError(CryptoMCPError):
+    code = "PROVIDER_ERROR"
+
+
+class InsufficientDataError(CryptoMCPError):
+    code = "INSUFFICIENT_DATA"
+
+
+class InvalidParamsError(CryptoMCPError):
+    code = "INVALID_PARAMS"
+
+
+class UnsupportedIndicatorError(CryptoMCPError):
+    code = "UNSUPPORTED_INDICATOR"
+
+
+class UnsupportedTimeframeError(CryptoMCPError):
+    code = "UNSUPPORTED_TIMEFRAME"

+ 75 - 0
indicators/__init__.py

@@ -0,0 +1,75 @@
+"""Technical indicators."""
+
+from errors import InsufficientDataError, UnsupportedIndicatorError
+
+
+def _closes(candles: list) -> list[float]:
+    return [float(c[4]) for c in candles]
+
+
+def _ema_series(values: list[float], period: int) -> list[float]:
+    if len(values) < period:
+        raise InsufficientDataError(f"EMA({period}) requires at least {period} candles, got {len(values)}")
+    k = 2 / (period + 1)
+    ema_vals = [sum(values[:period]) / period]
+    for v in values[period:]:
+        ema_vals.append(v * k + ema_vals[-1] * (1 - k))
+    return ema_vals
+
+
+def ema(candles: list, period: int = 20) -> float:
+    return round(_ema_series(_closes(candles), period)[-1], 6)
+
+
+def rsi(candles: list, period: int = 14) -> float:
+    closes = _closes(candles)
+    if len(closes) < period + 1:
+        raise InsufficientDataError(f"RSI({period}) requires at least {period + 1} candles, got {len(closes)}")
+    deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
+    gains = [max(d, 0.0) for d in deltas]
+    losses = [abs(min(d, 0.0)) for d in deltas]
+    avg_gain = sum(gains[:period]) / period
+    avg_loss = sum(losses[:period]) / period
+    for i in range(period, len(deltas)):
+        avg_gain = (avg_gain * (period - 1) + gains[i]) / period
+        avg_loss = (avg_loss * (period - 1) + losses[i]) / period
+    if avg_loss == 0:
+        return 100.0
+    rs = avg_gain / avg_loss
+    return round(100 - (100 / (1 + rs)), 2)
+
+
+def macd(candles: list, fast_period: int = 12, slow_period: int = 26, signal_period: int = 9) -> dict:
+    closes = _closes(candles)
+    if len(closes) < slow_period + signal_period:
+        raise InsufficientDataError("MACD requires more candles")
+    fast_ema = _ema_series(closes, fast_period)
+    slow_ema = _ema_series(closes, slow_period)
+    offset = len(fast_ema) - len(slow_ema)
+    macd_line = [fast_ema[i + offset] - slow_ema[i] for i in range(len(slow_ema))]
+    signal_line = _ema_series(macd_line, signal_period)
+    macd_val = round(macd_line[-1], 6)
+    signal_val = round(signal_line[-1], 6)
+    return {"macd": macd_val, "signal": signal_val, "histogram": round(macd_val - signal_val, 6)}
+
+
+def sma(candles: list, period: int = 20) -> float:
+    closes = _closes(candles)
+    if len(closes) < period:
+        raise InsufficientDataError(f"SMA({period}) requires at least {period} candles, got {len(closes)}")
+    return round(sum(closes[-period:]) / period, 6)
+
+
+def compute_indicator(candles: list, indicator: str, params: dict) -> dict:
+    ind = indicator.lower()
+    if ind == "rsi":
+        value = rsi(candles, period=int(params.get("period", 14)))
+    elif ind == "ema":
+        value = ema(candles, period=int(params.get("period", 20)))
+    elif ind == "macd":
+        value = macd(candles, fast_period=int(params.get("fast_period", 12)), slow_period=int(params.get("slow_period", 26)), signal_period=int(params.get("signal_period", 9)))
+    elif ind == "sma":
+        value = sma(candles, period=int(params.get("period", 20)))
+    else:
+        raise UnsupportedIndicatorError(f"Unsupported indicator: {indicator}")
+    return {"indicator": ind, "value": value}

+ 31 - 0
killserver.sh

@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+PIDFILE=${PIDFILE:-server.pid}
+
+stop_pid() {
+  local pid="$1"
+  if [ -n "$pid" ] && ps -p "$pid" > /dev/null 2>&1; then
+    kill "$pid" 2>/dev/null || true
+    sleep 1
+    if ps -p "$pid" > /dev/null 2>&1; then
+      kill -9 "$pid" 2>/dev/null || true
+    fi
+    echo "Stopped stale process $pid"
+  fi
+}
+
+if [ -f "$PIDFILE" ]; then
+  PID=$(cat "$PIDFILE" 2>/dev/null || true)
+  stop_pid "$PID"
+  rm -f "$PIDFILE"
+fi
+
+# Sweep up any stale listeners started from this project.
+PIDS=$(pgrep -f 'uvicorn .*main:app|python.*server.py|uvicorn .*server:app' || true)
+if [ -n "$PIDS" ]; then
+  for pid in $PIDS; do
+    stop_pid "$pid"
+  done
+else
+  echo "No stale processes found"
+fi

+ 227 - 0
main.py

@@ -0,0 +1,227 @@
+"""
+Crypto MCP Server — FastAPI entry point.
+
+MCP endpoints:
+  GET  /tools           → list available MCP tools
+  POST /tools/{name}    → call a tool
+
+Internal:
+  GET  /health          → server health + cache stats
+"""
+
+import sys
+import os
+sys.path.insert(0, os.path.dirname(__file__))
+
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+from fastapi.middleware.cors import CORSMiddleware
+
+import services
+from mcp_tools import MCP_TOOLS
+from errors import CryptoMCPError
+from cache import get_cache_stats
+
+MCP_SPEC = {
+    "protocolVersion": "2024-11-05",
+    "serverInfo": {"name": "crypto-mcp", "version": "1.0.0"},
+}
+
+_sessions: dict[str, dict] = {}
+
+app = FastAPI(
+    title="Crypto MCP Server",
+    description="Agent-friendly crypto market data + technical indicators",
+    version="1.0.0",
+)
+
+
+@app.get("/")
+async def root():
+    return {"jsonrpc": "2.0", "result": {"tools": MCP_TOOLS}, "id": None}
+
+
+@app.get("/mcp")
+async def mcp_root():
+    return {"jsonrpc": "2.0", "result": {"tools": MCP_TOOLS}, "id": None}
+
+
+@app.post("/mcp")
+async def mcp_rpc(request: Request):
+    try:
+        payload = await request.json()
+    except Exception:
+        return _rpc_error(None, -32700, "Parse error")
+
+    if payload.get("jsonrpc") != "2.0":
+        return _rpc_error(payload.get("id"), -32600, "Invalid Request")
+
+    method = payload.get("method")
+    params = payload.get("params", {}) or {}
+    req_id = payload.get("id")
+
+    try:
+        if method == "initialize":
+            session_id = params.get("sessionId") or _new_session_id()
+            _sessions.setdefault(session_id, {"initialized": True})
+            return _rpc_result(req_id, {**MCP_SPEC, "sessionId": session_id, "capabilities": {"tools": {"listChanged": False}}})
+
+        if method in ("tools/list", "listTools"):
+            return _rpc_result(req_id, {"tools": MCP_TOOLS})
+
+        if method in ("tools/call", "callTool"):
+            name = params.get("name") or params.get("toolName")
+            arguments = params.get("arguments") or params.get("params") or {}
+            if not name:
+                return _rpc_error(req_id, -32602, "Missing tool name")
+            result = await _call_tool(name, arguments)
+            return _rpc_result(req_id, result)
+
+        if method == "ping":
+            return _rpc_result(req_id, {"ok": True})
+
+        return _rpc_error(req_id, -32601, f"Method not found: {method}")
+    except CryptoMCPError as exc:
+        return _rpc_error(req_id, 400, exc.to_dict())
+
+
+def _rpc_result(req_id, result):
+    return {"jsonrpc": "2.0", "id": req_id, "result": result}
+
+
+def _rpc_error(req_id, code, message):
+    return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
+
+
+def _new_session_id() -> str:
+    import uuid
+    return uuid.uuid4().hex
+
+
+async def _call_tool(tool_name: str, body: dict):
+    match tool_name:
+        case "get_price":
+            return await services.get_price(_require(body, "symbol"))
+        case "get_ohlcv":
+            return await services.get_ohlcv(_require(body, "symbol"), body.get("timeframe", "1h"), int(body.get("limit", 100)))
+        case "get_indicator":
+            return await services.get_indicator(_require(body, "symbol"), _require(body, "indicator"), body.get("timeframe", "1h"), body.get("params", {}))
+        case "get_market_snapshot":
+            return await services.get_market_snapshot(_require(body, "symbol"))
+        case "get_top_movers":
+            return await services.get_top_movers(int(body.get("limit", 10)))
+        case _:
+            return {"error": "TOOL_NOT_FOUND", "detail": f"No tool named '{tool_name}'"}
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+# ---------------------------------------------------------------------------
+# Global error handler
+# ---------------------------------------------------------------------------
+
+@app.exception_handler(CryptoMCPError)
+async def crypto_error_handler(request: Request, exc: CryptoMCPError):
+    return JSONResponse(status_code=400, content=exc.to_dict())
+
+
+@app.exception_handler(Exception)
+async def generic_error_handler(request: Request, exc: Exception):
+    return JSONResponse(
+        status_code=500,
+        content={"error": "INTERNAL_ERROR", "detail": str(exc)},
+    )
+
+
+# ---------------------------------------------------------------------------
+# Health
+# ---------------------------------------------------------------------------
+
+@app.get("/health")
+async def health():
+    return {"status": "ok", "cache": get_cache_stats()}
+
+
+# ---------------------------------------------------------------------------
+# MCP Tool Registry
+# ---------------------------------------------------------------------------
+
+@app.get("/tools")
+async def list_tools():
+    """Return all available MCP tool definitions."""
+    return {"tools": MCP_TOOLS}
+
+
+# ---------------------------------------------------------------------------
+# MCP Tool Dispatch
+# ---------------------------------------------------------------------------
+
+@app.post("/tools/{tool_name}")
+async def call_tool(tool_name: str, request: Request):
+    """
+    Dispatch a tool call by name.
+    Body: tool parameters as JSON object.
+    """
+    try:
+        body = await request.json()
+    except Exception:
+        body = {}
+
+    match tool_name:
+
+        case "get_price":
+            symbol = _require(body, "symbol")
+            return await services.get_price(symbol)
+
+        case "get_ohlcv":
+            symbol = _require(body, "symbol")
+            timeframe = body.get("timeframe", "1h")
+            limit = int(body.get("limit", 100))
+            return await services.get_ohlcv(symbol, timeframe, limit)
+
+        case "get_indicator":
+            symbol = _require(body, "symbol")
+            indicator = _require(body, "indicator")
+            timeframe = body.get("timeframe", "1h")
+            params = body.get("params", {})
+            return await services.get_indicator(symbol, indicator, timeframe, params)
+
+        case "get_market_snapshot":
+            symbol = _require(body, "symbol")
+            return await services.get_market_snapshot(symbol)
+
+        case "get_top_movers":
+            limit = int(body.get("limit", 10))
+            return await services.get_top_movers(limit)
+
+        case _:
+            return JSONResponse(
+                status_code=404,
+                content={"error": "TOOL_NOT_FOUND", "detail": f"No tool named '{tool_name}'"},
+            )
+
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+
+def _require(body: dict, key: str) -> str:
+    from errors import InvalidParamsError
+    val = body.get(key)
+    if not val:
+        raise InvalidParamsError(f"Missing required parameter: '{key}'")
+    return str(val)
+
+
+# ---------------------------------------------------------------------------
+# Dev runner
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run("main:app", host="0.0.0.0", port=8505, reload=True)

+ 9 - 0
mcp_tools.py

@@ -0,0 +1,9 @@
+"""MCP tool definitions."""
+
+MCP_TOOLS = [
+    {"name": "get_price", "description": "Get the current USD price of a cryptocurrency.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}},
+    {"name": "get_ohlcv", "description": "Get OHLCV candlestick data for a crypto asset.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}, "timeframe": {"type": "string", "default": "1h"}, "limit": {"type": "integer", "default": 100}} ,"required": ["symbol"]}},
+    {"name": "get_indicator", "description": "Compute a technical indicator for a crypto asset.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}, "indicator": {"type": "string"}, "timeframe": {"type": "string", "default": "1h"}, "params": {"type": "object", "default": {}}}, "required": ["symbol", "indicator"]}},
+    {"name": "get_market_snapshot", "description": "Get a compact market snapshot for a crypto asset.", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}, "required": ["symbol"]}},
+    {"name": "get_top_movers", "description": "Get top gaining and losing crypto assets by 24h % change.", "parameters": {"type": "object", "properties": {"limit": {"type": "integer", "default": 10}}, "required": []}},
+]

+ 21 - 0
providers/__init__.py

@@ -0,0 +1,21 @@
+"""Provider aggregation."""
+
+from .binance import fetch_ohlcv as binance_fetch_ohlcv, fetch_price as binance_fetch_price
+from .coingecko import fetch_ohlcv as cg_fetch_ohlcv, fetch_price as cg_fetch_price, fetch_top_movers
+
+
+async def fetch_price(symbol: str) -> dict:
+    try:
+        return await binance_fetch_price(symbol)
+    except Exception:
+        return await cg_fetch_price(symbol)
+
+
+async def fetch_ohlcv(symbol: str, timeframe: str, limit: int = 100) -> dict:
+    try:
+        return await binance_fetch_ohlcv(symbol, timeframe, limit)
+    except Exception:
+        return await cg_fetch_ohlcv(symbol, timeframe, limit)
+
+
+__all__ = ["fetch_price", "fetch_ohlcv", "fetch_top_movers"]

+ 65 - 0
providers/binance.py

@@ -0,0 +1,65 @@
+"""
+Binance data provider.
+Used primarily for high-quality OHLCV data (klines endpoint).
+No API key required for public market data.
+"""
+
+import httpx
+from config import BINANCE_BASE_URL, TIMEFRAME_TO_BINANCE
+from errors import SymbolNotFoundError, ProviderError, UnsupportedTimeframeError
+
+
+def _binance_symbol(symbol: str) -> str:
+    sym = symbol.upper()
+    if not sym.endswith("USDT"):
+        sym = sym + "USDT"
+    return sym
+
+
+async def fetch_price(symbol: str) -> dict:
+    import time
+    binance_sym = _binance_symbol(symbol)
+    url = f"{BINANCE_BASE_URL}/ticker/price"
+    params = {"symbol": binance_sym}
+
+    async with httpx.AsyncClient(timeout=10.0) as client:
+        try:
+            resp = await client.get(url, params=params)
+            if resp.status_code == 400:
+                raise SymbolNotFoundError(f"Symbol not found on Binance: {symbol}")
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            raise ProviderError(f"Binance HTTP error: {e.response.status_code}") from e
+        except httpx.RequestError as e:
+            raise ProviderError(f"Binance request failed: {e}") from e
+
+    data = resp.json()
+    return {"symbol": symbol.upper(), "price": float(data["price"]), "timestamp": int(time.time())}
+
+
+async def fetch_ohlcv(symbol: str, timeframe: str, limit: int = 100) -> dict:
+    interval = TIMEFRAME_TO_BINANCE.get(timeframe)
+    if not interval:
+        raise UnsupportedTimeframeError(f"Unsupported timeframe: {timeframe}. Supported: {list(TIMEFRAME_TO_BINANCE.keys())}")
+
+    binance_sym = _binance_symbol(symbol)
+    url = f"{BINANCE_BASE_URL}/klines"
+    params = {"symbol": binance_sym, "interval": interval, "limit": min(limit, 1000)}
+
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        try:
+            resp = await client.get(url, params=params)
+            if resp.status_code == 400:
+                raise SymbolNotFoundError(f"Symbol not found on Binance: {symbol}")
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            raise ProviderError(f"Binance klines HTTP error: {e.response.status_code}") from e
+        except httpx.RequestError as e:
+            raise ProviderError(f"Binance klines request failed: {e}") from e
+
+    raw = resp.json()
+    if not raw:
+        raise ProviderError(f"Binance returned empty klines for {symbol} {timeframe}")
+
+    candles = [[int(row[0] / 1000), float(row[1]), float(row[2]), float(row[3]), float(row[4]), float(row[5])] for row in raw]
+    return {"symbol": symbol.upper(), "timeframe": timeframe, "candles": candles}

+ 87 - 0
providers/coingecko.py

@@ -0,0 +1,87 @@
+"""
+CoinGecko data provider.
+No API key required for basic endpoints.
+"""
+
+import time
+import httpx
+
+from config import COINGECKO_BASE_URL, SYMBOL_TO_COINGECKO_ID
+from errors import SymbolNotFoundError, ProviderError
+
+
+def _resolve_coingecko_id(symbol: str) -> str:
+    sym = symbol.upper()
+    cg_id = SYMBOL_TO_COINGECKO_ID.get(sym)
+    if not cg_id:
+        raise SymbolNotFoundError(f"Unknown symbol: {symbol}. Not in CoinGecko map.")
+    return cg_id
+
+
+async def fetch_price(symbol: str) -> dict:
+    cg_id = _resolve_coingecko_id(symbol)
+    url = f"{COINGECKO_BASE_URL}/simple/price"
+    params = {"ids": cg_id, "vs_currencies": "usd", "include_last_updated_at": "true"}
+
+    async with httpx.AsyncClient(timeout=10.0) as client:
+        try:
+            resp = await client.get(url, params=params)
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            raise ProviderError(f"CoinGecko HTTP error: {e.response.status_code}") from e
+        except httpx.RequestError as e:
+            raise ProviderError(f"CoinGecko request failed: {e}") from e
+
+    data = resp.json()
+    if cg_id not in data:
+        raise SymbolNotFoundError(f"CoinGecko returned no data for {symbol}")
+
+    entry = data[cg_id]
+    return {"symbol": symbol.upper(), "price": float(entry["usd"]), "timestamp": int(entry.get("last_updated_at", time.time()))}
+
+
+async def fetch_ohlcv(symbol: str, timeframe: str, limit: int = 100) -> dict:
+    cg_id = _resolve_coingecko_id(symbol)
+    days_map = {"1m": 1, "5m": 1, "15m": 1, "1h": 7, "4h": 14, "1d": 90}
+    days = days_map.get(timeframe, 7)
+
+    url = f"{COINGECKO_BASE_URL}/coins/{cg_id}/ohlc"
+    params = {"vs_currency": "usd", "days": days}
+
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        try:
+            resp = await client.get(url, params=params)
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            raise ProviderError(f"CoinGecko OHLC HTTP error: {e.response.status_code}") from e
+        except httpx.RequestError as e:
+            raise ProviderError(f"CoinGecko OHLC request failed: {e}") from e
+
+    raw = resp.json()
+    if not raw:
+        raise ProviderError(f"CoinGecko returned empty OHLCV for {symbol}")
+
+    candles = [[int(row[0] / 1000), float(row[1]), float(row[2]), float(row[3]), float(row[4]), 0.0] for row in raw]
+    return {"symbol": symbol.upper(), "timeframe": timeframe, "candles": candles[-limit:]}
+
+
+async def fetch_top_movers(limit: int = 10) -> dict:
+    url = f"{COINGECKO_BASE_URL}/coins/markets"
+    params = {"vs_currency": "usd", "order": "market_cap_desc", "per_page": 100, "page": 1, "price_change_percentage": "24h", "sparkline": "false"}
+
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        try:
+            resp = await client.get(url, params=params)
+            resp.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            raise ProviderError(f"CoinGecko markets HTTP error: {e.response.status_code}") from e
+        except httpx.RequestError as e:
+            raise ProviderError(f"CoinGecko markets request failed: {e}") from e
+
+    coins = resp.json()
+
+    def _format(coin: dict) -> dict:
+        return {"symbol": coin["symbol"].upper(), "name": coin["name"], "price": coin.get("current_price"), "change_24h_pct": coin.get("price_change_percentage_24h"), "market_cap": coin.get("market_cap")}
+
+    sorted_by_change = sorted([c for c in coins if c.get("price_change_percentage_24h") is not None], key=lambda c: c["price_change_percentage_24h"], reverse=True)
+    return {"gainers": [_format(c) for c in sorted_by_change[:limit]], "losers": [_format(c) for c in sorted_by_change[-limit:][::-1]]}

+ 8 - 0
requirements.txt

@@ -0,0 +1,8 @@
+fastapi>=0.111.0
+uvicorn>=0.29.0
+httpx>=0.27.0
+cachetools>=5.3.3
+pydantic>=2.7.0
+python-dotenv>=1.0.1
+pytest>=8.0.0
+mcp>=1.0.0

+ 4 - 0
restart.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+./killserver.sh
+./run.sh

+ 27 - 0
run.sh

@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT=${PORT:-8505}
+APP_MODULE=${APP_MODULE:-main:app}
+LOGFILE=${LOGFILE:-uvicorn.log}
+PIDFILE=${PIDFILE:-server.pid}
+
+mkdir -p "$(dirname "$LOGFILE")"
+
+if [ -f "$PIDFILE" ] && ps -p "$(cat "$PIDFILE" 2>/dev/null)" > /dev/null 2>&1; then
+  echo "Server already running (PID $(cat "$PIDFILE"))"
+  exit 0
+fi
+
+UVICORN_BIN="${UVICORN_BIN:-}"
+if [ -z "$UVICORN_BIN" ]; then
+  if [ -x ".venv/bin/uvicorn" ]; then
+    UVICORN_BIN=".venv/bin/uvicorn"
+  else
+    UVICORN_BIN="uvicorn"
+  fi
+fi
+
+nohup "$UVICORN_BIN" "$APP_MODULE" --host 0.0.0.0 --port "$PORT" > "$LOGFILE" 2>&1 &
+echo $! > "$PIDFILE"
+echo "Uvicorn started on port $PORT (PID $(cat "$PIDFILE"))"

+ 38 - 0
server.py

@@ -0,0 +1,38 @@
+"""Pure MCP server over stdio."""
+
+from mcp.server.fastmcp import FastMCP
+
+import services
+from mcp_tools import MCP_TOOLS
+
+
+mcp = FastMCP("crypto-mcp")
+
+
+@mcp.tool()
+async def get_price(symbol: str):
+    return await services.get_price(symbol)
+
+
+@mcp.tool()
+async def get_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100):
+    return await services.get_ohlcv(symbol, timeframe, limit)
+
+
+@mcp.tool()
+async def get_indicator(symbol: str, indicator: str, timeframe: str = "1h", params: dict | None = None):
+    return await services.get_indicator(symbol, indicator, timeframe, params or {})
+
+
+@mcp.tool()
+async def get_market_snapshot(symbol: str):
+    return await services.get_market_snapshot(symbol)
+
+
+@mcp.tool()
+async def get_top_movers(limit: int = 10):
+    return await services.get_top_movers(limit)
+
+
+if __name__ == "__main__":
+    mcp.run()

+ 58 - 0
services/__init__.py

@@ -0,0 +1,58 @@
+"""Service layer."""
+
+import time
+from config import DEFAULT_OHLCV_LIMIT, MAX_OHLCV_LIMIT
+from cache import get_cached_price, set_cached_price, get_cached_ohlcv, set_cached_ohlcv
+import providers
+import indicators as ind_module
+
+
+async def get_price(symbol: str) -> dict:
+    symbol = symbol.upper()
+    cached = get_cached_price(symbol)
+    if cached:
+        return cached
+    data = await providers.fetch_price(symbol)
+    set_cached_price(symbol, data)
+    return data
+
+
+async def get_ohlcv(symbol: str, timeframe: str, limit: int = DEFAULT_OHLCV_LIMIT) -> dict:
+    symbol = symbol.upper()
+    limit = min(max(limit, 1), MAX_OHLCV_LIMIT)
+    cached = get_cached_ohlcv(symbol, timeframe)
+    if cached:
+        result = dict(cached)
+        result["candles"] = cached["candles"][-limit:]
+        return result
+    data = await providers.fetch_ohlcv(symbol, timeframe, limit=MAX_OHLCV_LIMIT)
+    set_cached_ohlcv(symbol, timeframe, data)
+    result = dict(data)
+    result["candles"] = data["candles"][-limit:]
+    return result
+
+
+async def get_indicator(symbol: str, indicator: str, timeframe: str = "1h", params: dict = None, limit: int = 200) -> dict:
+    params = params or {}
+    symbol = symbol.upper()
+    ohlcv_data = await get_ohlcv(symbol, timeframe, limit=limit)
+    result = ind_module.compute_indicator(ohlcv_data["candles"], indicator, params)
+    return {"symbol": symbol, "indicator": result["indicator"], "timeframe": timeframe, "value": result["value"], "timestamp": int(time.time())}
+
+
+async def get_market_snapshot(symbol: str) -> dict:
+    symbol = symbol.upper()
+    price_data = await get_price(symbol)
+    ohlcv_data = await get_ohlcv(symbol, "1h", limit=200)
+    candles = ohlcv_data["candles"]
+    snapshot = {"symbol": symbol, "price": price_data["price"], "rsi_1h": None, "ema_20_1h": None, "ema_50_1h": None, "timestamp": price_data["timestamp"]}
+    for key, ind, params in [("rsi_1h", "rsi", {"period": 14}), ("ema_20_1h", "ema", {"period": 20}), ("ema_50_1h", "ema", {"period": 50})]:
+        try:
+            snapshot[key] = ind_module.compute_indicator(candles, ind, params)["value"]
+        except Exception:
+            pass
+    return snapshot
+
+
+async def get_top_movers(limit: int = 10) -> dict:
+    return await providers.fetch_top_movers(min(max(limit, 1), 50))

+ 238 - 0
tests.py

@@ -0,0 +1,238 @@
+"""
+Tests for indicators and cache logic.
+Run with: python -m pytest tests.py -v
+"""
+
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+
+import pytest
+import time
+from fastapi.testclient import TestClient
+from main import app
+from indicators import rsi, ema, macd, sma, compute_indicator
+from cache import TTLCache
+from errors import InsufficientDataError, UnsupportedIndicatorError
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+def make_candles(closes: list[float]) -> list:
+    """Create minimal candles from a close price list."""
+    return [[i * 3600, c * 0.99, c * 1.01, c * 0.98, c, 1000.0] for i, c in enumerate(closes)]
+
+
+# Realistic-ish BTC close prices (50 candles)
+SAMPLE_CLOSES = [
+    67000, 67200, 66800, 67500, 68000, 67800, 68200, 68500, 68300, 68700,
+    69000, 68900, 69200, 69500, 69100, 68800, 68600, 68400, 68200, 68000,
+    67800, 67600, 67400, 67200, 67000, 66800, 67200, 67500, 67800, 68100,
+    68400, 68700, 69000, 69300, 69600, 69900, 70200, 70500, 70800, 71000,
+    71200, 71000, 70800, 70600, 70400, 70200, 70000, 69800, 69600, 69400,
+]
+CANDLES = make_candles(SAMPLE_CLOSES)
+client = TestClient(app)
+
+
+# ---------------------------------------------------------------------------
+# EMA
+# ---------------------------------------------------------------------------
+
+class TestEMA:
+    def test_basic(self):
+        val = ema(CANDLES, period=20)
+        assert isinstance(val, float)
+        assert 60000 < val < 80000
+
+    def test_period_1(self):
+        val = ema(CANDLES, period=1)
+        # EMA(1) == last close
+        assert val == pytest.approx(SAMPLE_CLOSES[-1], rel=1e-4)
+
+    def test_insufficient_data(self):
+        with pytest.raises(InsufficientDataError):
+            ema(make_candles([100.0] * 5), period=10)
+
+    def test_ema_50_needs_more_than_20(self):
+        val_20 = ema(CANDLES, period=20)
+        val_50 = ema(CANDLES, period=50)
+        # With uptrend ending, EMA50 should be lower than EMA20
+        assert isinstance(val_50, float)
+
+
+# ---------------------------------------------------------------------------
+# RSI
+# ---------------------------------------------------------------------------
+
+class TestRSI:
+    def test_range(self):
+        val = rsi(CANDLES, period=14)
+        assert 0 <= val <= 100
+
+    def test_all_up(self):
+        # Consistently rising prices → RSI near 100
+        candles = make_candles([float(i) for i in range(1, 50)])
+        val = rsi(candles, period=14)
+        assert val > 80
+
+    def test_all_down(self):
+        # Consistently falling prices → RSI near 0
+        candles = make_candles([float(50 - i) for i in range(50)])
+        val = rsi(candles, period=14)
+        assert val < 20
+
+    def test_insufficient_data(self):
+        with pytest.raises(InsufficientDataError):
+            rsi(make_candles([100.0] * 5), period=14)
+
+
+# ---------------------------------------------------------------------------
+# MACD
+# ---------------------------------------------------------------------------
+
+class TestMACD:
+    def test_structure(self):
+        result = macd(CANDLES)
+        assert "macd" in result
+        assert "signal" in result
+        assert "histogram" in result
+
+    def test_histogram_math(self):
+        result = macd(CANDLES)
+        assert result["histogram"] == pytest.approx(
+            result["macd"] - result["signal"], rel=1e-5
+        )
+
+    def test_insufficient_data(self):
+        with pytest.raises(InsufficientDataError):
+            macd(make_candles([100.0] * 10))
+
+
+# ---------------------------------------------------------------------------
+# SMA
+# ---------------------------------------------------------------------------
+
+class TestSMA:
+    def test_basic(self):
+        # SMA of constant values == that value
+        candles = make_candles([500.0] * 30)
+        val = sma(candles, period=20)
+        assert val == pytest.approx(500.0, rel=1e-6)
+
+    def test_insufficient_data(self):
+        with pytest.raises(InsufficientDataError):
+            sma(make_candles([100.0] * 5), period=20)
+
+
+# ---------------------------------------------------------------------------
+# Compute Indicator Dispatcher
+# ---------------------------------------------------------------------------
+
+class TestDispatcher:
+    def test_rsi(self):
+        result = compute_indicator(CANDLES, "rsi", {"period": 14})
+        assert result["indicator"] == "rsi"
+        assert 0 <= result["value"] <= 100
+
+    def test_ema(self):
+        result = compute_indicator(CANDLES, "ema", {"period": 20})
+        assert result["indicator"] == "ema"
+
+    def test_macd(self):
+        result = compute_indicator(CANDLES, "macd", {})
+        assert result["indicator"] == "macd"
+        assert "histogram" in result["value"]
+
+    def test_unsupported(self):
+        with pytest.raises(UnsupportedIndicatorError):
+            compute_indicator(CANDLES, "bollinger", {})
+
+    def test_case_insensitive(self):
+        result = compute_indicator(CANDLES, "RSI", {"period": 14})
+        assert result["indicator"] == "rsi"
+
+
+# ---------------------------------------------------------------------------
+# TTL Cache
+# ---------------------------------------------------------------------------
+
+class TestTTLCache:
+    def test_set_get(self):
+        cache = TTLCache()
+        cache.set("k", {"x": 1}, ttl=60)
+        assert cache.get("k") == {"x": 1}
+
+    def test_expiry(self):
+        cache = TTLCache()
+        cache.set("k", "val", ttl=1)
+        time.sleep(1.1)
+        assert cache.get("k") is None
+
+    def test_missing_key(self):
+        cache = TTLCache()
+        assert cache.get("nonexistent") is None
+
+    def test_overwrite(self):
+        cache = TTLCache()
+        cache.set("k", "a", ttl=60)
+        cache.set("k", "b", ttl=60)
+        assert cache.get("k") == "b"
+
+    def test_delete(self):
+        cache = TTLCache()
+        cache.set("k", "v", ttl=60)
+        cache.delete("k")
+        assert cache.get("k") is None
+
+    def test_stats(self):
+        cache = TTLCache()
+        cache.set("a", 1, ttl=60)
+        cache.set("b", 2, ttl=60)
+        stats = cache.stats()
+        assert stats["alive_keys"] == 2
+
+
+# ---------------------------------------------------------------------------
+# HTTP / MCP surface
+# ---------------------------------------------------------------------------
+
+class TestHTTPMCP:
+    def test_root_returns_tools(self):
+        resp = client.get("/")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["jsonrpc"] == "2.0"
+        assert "tools" in body["result"]
+
+    def test_health(self):
+        resp = client.get("/health")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["status"] == "ok"
+        assert "cache" in body
+
+    def test_initialize_rpc(self):
+        resp = client.post(
+            "/mcp",
+            json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"clientInfo": {"name": "pytest", "version": "1.0"}}},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["jsonrpc"] == "2.0"
+        assert body["id"] == 1
+        assert "sessionId" in body["result"]
+
+    def test_tools_list_rpc(self):
+        resp = client.post(
+            "/mcp",
+            json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert len(body["result"]["tools"]) >= 1
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])

+ 10 - 0
tests.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+if [ -x ".venv/bin/python" ]; then
+  .venv/bin/python -m pytest tests.py -q
+else
+  python3 -m pytest tests.py -q
+fi