Initial commit: gridbot-mcp server

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-13 19:17:44 +02:00
commit c749c42bc4
4 changed files with 1309 additions and 0 deletions

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
# gridbot-mcp server

607
src/server.py Normal file
View File

@@ -0,0 +1,607 @@
"""
Gridbot MCP Server - Wraps eToroGridbot REST API as MCP tools.
Requires SSH tunnel: ssh -L 8000:localhost:8000 services
"""
import os
import json
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
BASE_URL = os.getenv("GRIDBOT_API_URL", "http://localhost:8000")
TIMEOUT = 30.0
server = Server("gridbot-mcp")
async def api_get(endpoint: str) -> dict[str, Any]:
"""Make GET request to gridbot API."""
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
try:
resp = await client.get(f"{BASE_URL}{endpoint}")
resp.raise_for_status()
return resp.json()
except httpx.ConnectError:
return {"error": "Connection failed - is SSH tunnel running?"}
except httpx.HTTPStatusError as e:
return {"error": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e:
return {"error": str(e)}
async def api_post(endpoint: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Make POST request to gridbot API."""
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
try:
resp = await client.post(f"{BASE_URL}{endpoint}", json=data or {})
resp.raise_for_status()
return resp.json()
except httpx.ConnectError:
return {"error": "Connection failed - is SSH tunnel running?"}
except httpx.HTTPStatusError as e:
return {"error": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e:
return {"error": str(e)}
async def api_delete(endpoint: str) -> dict[str, Any]:
"""Make DELETE request to gridbot API."""
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
try:
resp = await client.delete(f"{BASE_URL}{endpoint}")
resp.raise_for_status()
return resp.json()
except httpx.ConnectError:
return {"error": "Connection failed - is SSH tunnel running?"}
except httpx.HTTPStatusError as e:
return {"error": f"HTTP {e.response.status_code}: {e.response.text}"}
except Exception as e:
return {"error": str(e)}
@server.list_tools()
async def list_tools() -> list[Tool]:
"""List available gridbot tools."""
return [
Tool(
name="health",
description="Check gridbot conductor health status, queue size, and capacity",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="emergency_status",
description="Get emergency stop status, circuit breaker state, drawdown, and daily loss limits",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="market_hours",
description="Get market hours summary for all exchanges (NYSE, NASDAQ, CRYPTO, FOREX, etc)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="positions",
description="Get all portfolio positions with real-time P/L (uses BotPriceProvider, works on weekends)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="pending_orders",
description="Get all pending limit orders with amounts, rates, TP/SL, and headroom",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="risk_score",
description="Get current portfolio risk score and components (volatility, drawdown, concentration, leverage)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="vix",
description="Get VIX (volatility index) value and interpretation (LOW/NORMAL/ELEVATED/HIGH)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="crypto_fear_greed",
description="Get Crypto Fear & Greed index with classification and trading guidance",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="dashboard",
description="Get full dashboard summary including positions, orders, and analytics",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="analytics",
description="Get trading analytics summary (win rate, Sharpe ratio, P/L)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="validate_trade",
description="Validate a trade before execution (checks risk limits, concentration, etc)",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Ticker symbol (e.g., BTC, AAPL)"},
"side": {"type": "string", "enum": ["BUY", "SELL"], "description": "Trade direction"},
"amount": {"type": "number", "description": "Trade amount in USD"},
},
"required": ["symbol", "side", "amount"],
},
),
Tool(
name="queue_status",
description="Get signal processing queue status",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="emergency_stop",
description="HALT all trading (emergency use only)",
inputSchema={
"type": "object",
"properties": {
"reason": {"type": "string", "description": "Reason for emergency stop"},
"confirm": {"type": "boolean", "description": "Must be true to execute"},
},
"required": ["reason", "confirm"],
},
),
Tool(
name="emergency_reset",
description="Resume trading after emergency stop",
inputSchema={
"type": "object",
"properties": {
"confirm": {"type": "boolean", "description": "Must be true to execute"},
},
"required": ["confirm"],
},
),
Tool(
name="cleanup_stale_orders",
description="Cleanup stale/expired pending orders",
inputSchema={
"type": "object",
"properties": {
"confirm": {"type": "boolean", "description": "Must be true to execute"},
},
"required": ["confirm"],
},
),
# === NEW TOOLS ===
Tool(
name="bot_health",
description="Get health status of all trading bots (Freqtrade, Hummingbot, etc)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="stale_bots",
description="Get list of bots that haven't sent signals recently",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="correlations",
description="Get top correlated asset pairs in portfolio",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="trades",
description="Get recent trades with P/L details",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="expectancy",
description="Get trading expectancy metrics for all symbols",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="profitable_symbols",
description="Get list of profitable symbols ranked by expectancy",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="monitoring_stats",
description="Get system monitoring stats (signal counts, rejection reasons, latency)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="statistics",
description="Get trading statistics summary (win rate, avg P/L, trade counts)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="support_resistance",
description="Get support/resistance levels for a symbol",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Ticker symbol (e.g., BTC, AAPL)"},
},
"required": ["symbol"],
},
),
Tool(
name="signal_quality",
description="Get signal quality report (acceptance rate, rejection reasons)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="trend_alerts",
description="Get active trend change alerts for monitored positions",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="env_health",
description="Get environment health (API keys, credentials status)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="candle_freshness",
description="Get candle data freshness (staleness check for each instrument)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="rate_limiter",
description="Get rate limiter statistics (requests per minute, throttled count)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="portfolio_allocation",
description="Get portfolio allocation breakdown (crypto %, stocks %, metals %)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="create_order",
description="Create a new order (BUY/SELL) with full validation pipeline. Goes through all risk checks.",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Trading symbol (e.g., GLD, NVDA, BTC)"},
"action": {"type": "string", "enum": ["BUY", "SELL"], "description": "Trade direction"},
"amount": {"type": "number", "description": "Position size in USD (max 10000)"},
"order_type": {"type": "string", "enum": ["MARKET", "LIMIT"], "default": "LIMIT", "description": "Order type"},
"limit_price": {"type": "number", "description": "Limit price (required for LIMIT orders, omit for MARKET)"},
"take_profit_pct": {"type": "number", "description": "Take profit percentage (e.g., 10 for 10%)"},
"stop_loss_pct": {"type": "number", "description": "Stop loss percentage (e.g., 5 for 5%)"},
"dry_run": {"type": "boolean", "default": False, "description": "Validate only, do not execute"},
},
"required": ["symbol", "action", "amount"],
},
),
Tool(
name="close_position",
description="Close an existing position (full or partial). Use position_id from positions tool.",
inputSchema={
"type": "object",
"properties": {
"position_id": {"type": "integer", "description": "eToro position ID to close"},
"close_percentage": {"type": "number", "description": "Percentage to close (1-100). Omit for full close."},
},
"required": ["position_id"],
},
),
# === SIGNAL INTENTS ===
Tool(
name="intents",
description="List signal intents (pending orders awaiting trigger price). Filter by status or symbol.",
inputSchema={
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["pending", "executed", "expired", "rejected"], "description": "Filter by status"},
"symbol": {"type": "string", "description": "Filter by symbol (e.g., BTC, AAPL)"},
"limit": {"type": "number", "default": 50, "description": "Max results (1-200)"},
},
},
),
Tool(
name="intents_pending",
description="List pending signal intents awaiting execution (shortcut for intents with status=pending)",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Filter by symbol (e.g., BTC, AAPL)"},
"limit": {"type": "number", "default": 50, "description": "Max results (1-200)"},
},
},
),
Tool(
name="intents_executed",
description="List executed signal intents (orders that were triggered and filled)",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Filter by symbol (e.g., BTC, AAPL)"},
"limit": {"type": "number", "default": 50, "description": "Max results (1-200)"},
},
},
),
Tool(
name="intents_stats",
description="Get signal intent statistics (pending/executed/expired counts, execution rate, amounts)",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="create_intent",
description="Create a signal intent (limit order that executes when price reaches trigger). Use for weekend/after-hours trading.",
inputSchema={
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Trading symbol (e.g., BTC, NVDA, COIN)"},
"is_buy": {"type": "boolean", "default": True, "description": "True for BUY, False for SELL"},
"amount": {"type": "number", "description": "Position size in USD (max 10000)"},
"trigger_price": {"type": "number", "description": "Price at which to trigger execution"},
"take_profit_pct": {"type": "number", "description": "Take profit % (e.g., 10 for 10%). Default: 15% crypto, 9% stocks"},
"stop_loss_pct": {"type": "number", "description": "Stop loss % (e.g., 5 for 5%). Default: 10% crypto, 6% stocks"},
"expires_in_hours": {"type": "number", "description": "Hours until expiration (default: 168 stocks, 72 crypto)"},
"source_type": {"type": "string", "default": "manual", "description": "Signal source: manual, freqtrade, hummingbot, pi_post, newsletter"},
},
"required": ["symbol", "amount", "trigger_price"],
},
),
Tool(
name="cancel_intent",
description="Cancel a pending signal intent by ID",
inputSchema={
"type": "object",
"properties": {
"intent_id": {"type": "string", "description": "Intent ID to cancel"},
},
"required": ["intent_id"],
},
),
# === ORDER MANAGEMENT ===
Tool(
name="cancel_order",
description="Cancel a single pending limit order by order_id",
inputSchema={
"type": "object",
"properties": {
"order_id": {"type": "integer", "description": "Order ID to cancel (from pending_orders)"},
},
"required": ["order_id"],
},
),
Tool(
name="cancel_orders_bulk",
description="Cancel multiple pending orders at once. Use for cleaning up duplicates.",
inputSchema={
"type": "object",
"properties": {
"order_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "List of order IDs to cancel",
},
"confirm": {"type": "boolean", "description": "Must be true to execute"},
},
"required": ["order_ids", "confirm"],
},
),
Tool(
name="find_duplicate_orders",
description="Find duplicate pending orders (same instrument + rate + direction). Returns order IDs to cancel.",
inputSchema={"type": "object", "properties": {}, "required": []},
),
# === CONTAINER OPERATIONS ===
Tool(
name="container_logs",
description="Get container logs with optional grep filter. Allowed: conductor, freqtrade, hummingbot, dashboard, postgres, redis.",
inputSchema={
"type": "object",
"properties": {
"container": {"type": "string", "enum": ["conductor", "freqtrade", "hummingbot", "dashboard", "postgres", "redis"], "description": "Container name"},
"tail": {"type": "number", "default": 100, "description": "Number of lines (1-1000)"},
"grep": {"type": "string", "description": "Grep pattern to filter logs"},
"since": {"type": "string", "description": "Show logs since (e.g., '1h', '30m')"},
},
"required": ["container"],
},
),
Tool(
name="container_restart",
description="Restart a container (requires confirm=true). Allowed: conductor, freqtrade, hummingbot, dashboard, postgres, redis.",
inputSchema={
"type": "object",
"properties": {
"container": {"type": "string", "enum": ["conductor", "freqtrade", "hummingbot", "dashboard", "postgres", "redis"], "description": "Container to restart"},
"confirm": {"type": "boolean", "description": "Must be true to execute"},
},
"required": ["container", "confirm"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
"""Execute a gridbot tool."""
result: dict[str, Any]
match name:
case "health":
result = await api_get("/health")
case "emergency_status":
result = await api_get("/emergency/status")
case "market_hours":
result = await api_get("/api/market-hours/summary")
case "positions":
result = await api_get("/portfolio/positions/detailed")
case "pending_orders":
result = await api_get("/portfolio/pending-orders")
case "risk_score":
result = await api_get("/api/risk-score/latest")
case "vix":
result = await api_get("/api/market-intel/vix")
case "crypto_fear_greed":
result = await api_get("/api/market-intel/crypto-fear-greed")
case "dashboard":
result = await api_get("/api/dashboard/summary")
case "analytics":
result = await api_get("/api/analytics/summary")
case "queue_status":
result = await api_get("/queue/status")
case "validate_trade":
result = await api_post("/portfolio/validate-trade", {
"symbol": arguments["symbol"],
"side": arguments["side"],
"amount": arguments["amount"],
})
case "emergency_stop":
if not arguments.get("confirm"):
result = {"error": "Must set confirm=true to execute emergency stop"}
else:
result = await api_post("/emergency/stop", {
"reason": arguments.get("reason", "Manual halt"),
"confirmation": True,
})
case "emergency_reset":
if not arguments.get("confirm"):
result = {"error": "Must set confirm=true to reset emergency stop"}
else:
result = await api_post("/emergency/reset", {"confirmation": True})
case "cleanup_stale_orders":
if not arguments.get("confirm"):
result = {"error": "Must set confirm=true to cleanup orders"}
else:
result = await api_post("/api/order-ttl/cleanup", {"confirmation": True})
# === NEW TOOL HANDLERS ===
case "bot_health":
result = await api_get("/api/bots/health")
case "stale_bots":
result = await api_get("/api/bots/stale")
case "correlations":
result = await api_get("/api/correlations/top")
case "trades":
result = await api_get("/api/dashboard/trades")
case "expectancy":
result = await api_get("/api/expectancy/")
case "profitable_symbols":
result = await api_get("/api/expectancy/profitable/list")
case "monitoring_stats":
result = await api_get("/api/monitoring/stats")
case "statistics":
result = await api_get("/api/statistics/summary")
case "support_resistance":
symbol = arguments.get("symbol", "BTC")
result = await api_get(f"/api/support-resistance/levels?symbol={symbol}")
case "signal_quality":
result = await api_get("/api/signal-quality/report")
case "trend_alerts":
result = await api_get("/api/trend-monitoring/alerts")
case "env_health":
result = await api_get("/api/health/env")
case "candle_freshness":
result = await api_get("/api/monitoring/candle-cache/stats")
case "rate_limiter":
result = await api_get("/api/monitoring/rate_limiter/stats")
case "portfolio_allocation":
result = await api_get("/api/portfolio/status")
case "create_order":
payload = {
"symbol": arguments["symbol"],
"action": arguments["action"],
"amount": arguments["amount"],
"order_type": arguments.get("order_type", "LIMIT"),
"take_profit_pct": arguments.get("take_profit_pct", 10),
"stop_loss_pct": arguments.get("stop_loss_pct", 5),
"dry_run": arguments.get("dry_run", False),
}
if arguments.get("limit_price"):
payload["limit_price"] = arguments["limit_price"]
result = await api_post("/api/trade/order", payload)
case "close_position":
payload = {"position_id": arguments["position_id"]}
if arguments.get("close_percentage"):
payload["close_percentage"] = arguments["close_percentage"]
result = await api_post("/api/trade/close", payload)
# === SIGNAL INTENTS ===
case "intents":
params = []
if arguments.get("status"):
params.append(f"status={arguments['status']}")
if arguments.get("symbol"):
params.append(f"symbol={arguments['symbol']}")
if arguments.get("limit"):
params.append(f"limit={arguments['limit']}")
query = f"?{'&'.join(params)}" if params else ""
result = await api_get(f"/intents{query}")
case "intents_pending":
params = []
if arguments.get("symbol"):
params.append(f"symbol={arguments['symbol']}")
if arguments.get("limit"):
params.append(f"limit={arguments['limit']}")
query = f"?{'&'.join(params)}" if params else ""
result = await api_get(f"/intents/pending{query}")
case "intents_executed":
params = []
if arguments.get("symbol"):
params.append(f"symbol={arguments['symbol']}")
if arguments.get("limit"):
params.append(f"limit={arguments['limit']}")
query = f"?{'&'.join(params)}" if params else ""
result = await api_get(f"/intents/executed{query}")
case "intents_stats":
result = await api_get("/intents/stats")
case "create_intent":
payload = {
"symbol": arguments["symbol"],
"is_buy": arguments.get("is_buy", True),
"amount": arguments["amount"],
"trigger_price": arguments["trigger_price"],
}
if arguments.get("take_profit_pct"):
payload["take_profit_pct"] = arguments["take_profit_pct"]
if arguments.get("stop_loss_pct"):
payload["stop_loss_pct"] = arguments["stop_loss_pct"]
if arguments.get("expires_in_hours"):
payload["expires_in_hours"] = arguments["expires_in_hours"]
if arguments.get("source_type"):
payload["source_type"] = arguments["source_type"]
result = await api_post("/intents", payload)
case "cancel_intent":
intent_id = arguments["intent_id"]
result = await api_delete(f"/intents/{intent_id}")
# === ORDER MANAGEMENT ===
case "cancel_order":
order_id = arguments["order_id"]
result = await api_delete(f"/api/trade/order/{order_id}")
case "cancel_orders_bulk":
if not arguments.get("confirm"):
result = {"error": "Must set confirm=true to cancel orders"}
else:
order_ids = arguments["order_ids"]
result = await api_post("/api/trade/orders/cancel-bulk", {"order_ids": order_ids})
case "find_duplicate_orders":
result = await api_get("/api/trade/orders/duplicates")
# === CONTAINER OPERATIONS ===
case "container_logs":
params = [f"container={arguments['container']}"]
if arguments.get("tail"):
params.append(f"tail={arguments['tail']}")
if arguments.get("grep"):
params.append(f"grep={arguments['grep']}")
if arguments.get("since"):
params.append(f"since={arguments['since']}")
result = await api_get(f"/containers/logs?{'&'.join(params)}")
case "container_restart":
result = await api_post(
f"/containers/restart?container={arguments['container']}&confirm={str(arguments.get('confirm', False)).lower()}"
)
case _:
result = {"error": f"Unknown tool: {name}"}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())