Spaces:
Sleeping
Sleeping
| # ============================================================================= | |||
| # app/mcp.py | |||
| # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture | |||
| # Copyright 2026 - Volkan Kücükbudak | |||
| # Apache License V. 2 + ESOL 1.1 | |||
| # Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed | |||
| # ============================================================================= | |||
| # ARCHITECTURE NOTE: | |||
| # This file lives exclusively in app/ and is ONLY started by app/app.py. | |||
| # NO direct access to fundaments/*, .env, or Guardian (main.py). | |||
| # All config comes from app/.pyfun via app/config.py. | |||
| # | |||
| # MCP SSE transport runs through Quart/hypercorn via /mcp route. | |||
| # All MCP traffic can be intercepted, logged, and transformed in app.py | |||
| # before reaching the MCP handler — this is by design. | |||
| # | |||
| # TOOL REGISTRATION PRINCIPLE: | |||
| # Tools are only registered if their required ENV key exists. | |||
| # No key = no tool = no crash. Server always starts, just with fewer tools. | |||
| # ENV key NAMES come from app/.pyfun — values are never touched here. | |||
| # ============================================================================= | |||
| import asyncio | |||
| import logging | |||
| import os | |||
| from typing import Dict, Any | |||
| from . import config as app_config # reads app/.pyfun — only config source for app/* | |||
| # from . import polymarket | |||
| logger = logging.getLogger('mcp') | |||
| # Global MCP instance — initialized once via initialize() | |||
| _mcp = None | |||
| async def initialize() -> None: | |||
| """ | |||
| Initializes the MCP instance and registers all tools. | |||
| Called once by app/app.py during startup. | |||
| No fundaments passed in — sandboxed. | |||
| """ | |||
| global _mcp | |||
| logger.info("MCP Hub initializing...") | |||
| hub_cfg = app_config.get_hub() | |||
| try: | |||
| from mcp.server.fastmcp import FastMCP | |||
| except ImportError: | |||
| logger.critical("FastMCP not installed. Run: pip install mcp") | |||
| raise | |||
| _mcp = FastMCP( | |||
| name=hub_cfg.get("HUB_NAME", "Universal MCP Hub"), | |||
| instructions=( | |||
| f"{hub_cfg.get('HUB_DESCRIPTION', 'Universal MCP Hub on PyFundaments')} " | |||
| "Use list_active_tools to see what is currently available." | |||
| ) | |||
| ) | |||
| # --- Register tools --- | |||
| _register_llm_tools(_mcp) | |||
| _register_search_tools(_mcp) | |||
| # _register_db_tools(_mcp) # uncomment when db_sync is ready | |||
| _register_system_tools(_mcp) | |||
| _register_polymarket_tools(_mcp) | |||
| logger.info("MCP Hub initialized.") | |||
| async def handle_request(request) -> None: | |||
| """ | |||
| Handles incoming MCP SSE requests routed through Quart /mcp endpoint. | |||
| This is the interceptor point — add auth, logging, rate limiting here. | |||
| """ | |||
| if _mcp is None: | |||
| logger.error("MCP not initialized — call initialize() first.") | |||
| from quart import jsonify | |||
| return jsonify({"error": "MCP not initialized"}), 503 | |||
| # --- Interceptor hooks (add as needed) --- | |||
| # logger.debug(f"MCP request: {request.method} {request.path}") | |||
| # await _check_auth(request) | |||
| # await _rate_limit(request) | |||
| # await _log_payload(request) | |||
| # --- Forward to FastMCP SSE handler --- | |||
| return await _mcp.handle_sse(request) | |||
| # ============================================================================= | |||
| # Tool registration helpers | |||
| # ============================================================================= | |||
| def _register_llm_tools(mcp) -> None: | |||
| """Register LLM tools based on active providers in app/.pyfun + ENV key check.""" | |||
| active = app_config.get_active_llm_providers() | |||
| for name, cfg in active.items(): | |||
| env_key = cfg.get("env_key", "") | |||
| if not env_key or not os.getenv(env_key): | |||
| logger.info(f"LLM provider '{name}' skipped — ENV key '{env_key}' not set.") | |||
| continue | |||
| if name == "anthropic": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _api_ver = cfg.get("api_version_header", "2023-06-01") | |||
| _base_url = cfg.get("base_url", "https://api.anthropic.com/v1") | |||
| _def_model = cfg.get("default_model", "claude-haiku-4-5-20251001") | |||
| async def anthropic_complete( | |||
| prompt: str, | |||
| model: str = _def_model, | |||
| max_tokens: int = 1024 | |||
| ) -> str: | |||
| """Send a prompt to Anthropic Claude.""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.post( | |||
| f"{_base_url}/messages", | |||
| headers={ | |||
| "x-api-key": _key, | |||
| "anthropic-version": _api_ver, | |||
| "content-type": "application/json" | |||
| }, | |||
| json={ | |||
| "model": model, | |||
| "max_tokens": max_tokens, | |||
| "messages": [{"role": "user", "content": prompt}] | |||
| }, | |||
| timeout=60.0 | |||
| ) | |||
| r.raise_for_status() | |||
| return r.json()["content"][0]["text"] | |||
| logger.info(f"Tool registered: anthropic_complete (model: {_def_model})") | |||
| elif name == "gemini": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _base_url = cfg.get("base_url", "https://generativelanguage.googleapis.com/v1beta") | |||
| _def_model = cfg.get("default_model", "gemini-2.0-flash") | |||
| async def gemini_complete( | |||
| prompt: str, | |||
| model: str = _def_model, | |||
| max_tokens: int = 1024 | |||
| ) -> str: | |||
| """Send a prompt to Google Gemini.""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.post( | |||
| f"{_base_url}/models/{model}:generateContent", | |||
| params={"key": _key}, | |||
| json={ | |||
| "contents": [{"parts": [{"text": prompt}]}], | |||
| "generationConfig": {"maxOutputTokens": max_tokens} | |||
| }, | |||
| timeout=60.0 | |||
| ) | |||
| r.raise_for_status() | |||
| return r.json()["candidates"][0]["content"]["parts"][0]["text"] | |||
| logger.info(f"Tool registered: gemini_complete (model: {_def_model})") | |||
| elif name == "openrouter": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _base_url = cfg.get("base_url", "https://openrouter.ai/api/v1") | |||
| _def_model = cfg.get("default_model", "mistralai/mistral-7b-instruct") | |||
| _referer = os.getenv("APP_URL", "https://huggingface.co") | |||
| async def openrouter_complete( | |||
| prompt: str, | |||
| model: str = _def_model, | |||
| max_tokens: int = 1024 | |||
| ) -> str: | |||
| """Send a prompt via OpenRouter (100+ models).""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.post( | |||
| f"{_base_url}/chat/completions", | |||
| headers={ | |||
| "Authorization": f"Bearer {_key}", | |||
| "HTTP-Referer": _referer, | |||
| "content-type": "application/json" | |||
| }, | |||
| json={ | |||
| "model": model, | |||
| "max_tokens": max_tokens, | |||
| "messages": [{"role": "user", "content": prompt}] | |||
| }, | |||
| timeout=60.0 | |||
| ) | |||
| r.raise_for_status() | |||
| return r.json()["choices"][0]["message"]["content"] | |||
| logger.info(f"Tool registered: openrouter_complete (model: {_def_model})") | |||
| elif name == "huggingface": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _base_url = cfg.get("base_url", "/static-proxy?url=https%3A%2F%2Fapi-inference.huggingface.co%2Fmodels%26quot%3B%3C%2Fspan%3E)%3C!----%3E%3C%2Ftd%3E%3C%2Ftr%3E%3Ctr id="L207"> | _def_model = cfg.get("default_model", "mistralai/Mistral-7B-Instruct-v0.3") | ||
| async def hf_inference( | |||
| prompt: str, | |||
| model: str = _def_model, | |||
| max_tokens: int = 512 | |||
| ) -> str: | |||
| """Send a prompt to HuggingFace Inference API.""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.post( | |||
| f"{_base_url}/{model}/v1/chat/completions", | |||
| headers={ | |||
| "Authorization": f"Bearer {_key}", | |||
| "content-type": "application/json" | |||
| }, | |||
| json={ | |||
| "model": model, | |||
| "max_tokens": max_tokens, | |||
| "messages": [{"role": "user", "content": prompt}] | |||
| }, | |||
| timeout=120.0 | |||
| ) | |||
| r.raise_for_status() | |||
| return r.json()["choices"][0]["message"]["content"] | |||
| logger.info(f"Tool registered: hf_inference (model: {_def_model})") | |||
| else: | |||
| logger.info(f"LLM provider '{name}' has no tool handler yet — skipped.") | |||
| def _register_search_tools(mcp) -> None: | |||
| """Register search tools based on active providers in app/.pyfun + ENV key check.""" | |||
| active = app_config.get_active_search_providers() | |||
| for name, cfg in active.items(): | |||
| env_key = cfg.get("env_key", "") | |||
| if not env_key or not os.getenv(env_key): | |||
| logger.info(f"Search provider '{name}' skipped — ENV key '{env_key}' not set.") | |||
| continue | |||
| if name == "brave": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _base_url = cfg.get("base_url", "https://api.search.brave.com/res/v1/web/search") | |||
| _def_results = int(cfg.get("default_results", "5")) | |||
| _max_results = int(cfg.get("max_results", "20")) | |||
| async def brave_search(query: str, count: int = _def_results) -> str: | |||
| """Search the web via Brave Search API.""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.get( | |||
| _base_url, | |||
| headers={ | |||
| "Accept": "application/json", | |||
| "X-Subscription-Token": _key | |||
| }, | |||
| params={"q": query, "count": min(count, _max_results)}, | |||
| timeout=30.0 | |||
| ) | |||
| r.raise_for_status() | |||
| results = r.json().get("web", {}).get("results", []) | |||
| if not results: | |||
| return "No results found." | |||
| return "\n\n".join([ | |||
| f"{i}. {res.get('title', '')}\n {res.get('url', '')}\n {res.get('description', '')}" | |||
| for i, res in enumerate(results, 1) | |||
| ]) | |||
| logger.info("Tool registered: brave_search") | |||
| elif name == "tavily": | |||
| import httpx | |||
| _key = os.getenv(env_key) | |||
| _base_url = cfg.get("base_url", "https://api.tavily.com/search") | |||
| _def_results = int(cfg.get("default_results", "5")) | |||
| _incl_answer = cfg.get("include_answer", "true").lower() == "true" | |||
| async def tavily_search(query: str, max_results: int = _def_results) -> str: | |||
| """AI-optimized web search via Tavily.""" | |||
| async with httpx.AsyncClient() as client: | |||
| r = await client.post( | |||
| _base_url, | |||
| json={ | |||
| "api_key": _key, | |||
| "query": query, | |||
| "max_results": max_results, | |||
| "include_answer": _incl_answer | |||
| }, | |||
| timeout=30.0 | |||
| ) | |||
| r.raise_for_status() | |||
| data = r.json() | |||
| parts = [] | |||
| if data.get("answer"): | |||
| parts.append(f"Summary: {data['answer']}") | |||
| for res in data.get("results", []): | |||
| parts.append( | |||
| f"- {res['title']}\n {res['url']}\n {res.get('content', '')[:200]}..." | |||
| ) | |||
| return "\n\n".join(parts) | |||
| logger.info("Tool registered: tavily_search") | |||
| else: | |||
| logger.info(f"Search provider '{name}' has no tool handler yet — skipped.") | |||
| def _register_system_tools(mcp) -> None: | |||
| """System tools — always registered, no ENV key required.""" | |||
| def list_active_tools() -> Dict[str, Any]: | |||
| """Show active providers and configured integrations (key names only, never values).""" | |||
| llm = app_config.get_active_llm_providers() | |||
| search = app_config.get_active_search_providers() | |||
| hub = app_config.get_hub() | |||
| return { | |||
| "hub": hub.get("HUB_NAME", "Universal MCP Hub"), | |||
| "version": hub.get("HUB_VERSION", ""), | |||
| "active_llm_providers": [n for n, c in llm.items() if os.getenv(c.get("env_key", ""))], | |||
| "active_search_providers":[n for n, c in search.items() if os.getenv(c.get("env_key", ""))], | |||
| } | |||
| logger.info("Tool registered: list_active_tools") | |||
| def health_check() -> Dict[str, str]: | |||
| """Health check for monitoring and HuggingFace Spaces.""" | |||
| return {"status": "ok", "service": "Universal MCP Hub"} | |||
| logger.info("Tool registered: health_check") | |||
| # 3. Neue Funktion — analog zu _register_search_tools(): | |||
| def _register_polymarket_tools(mcp) -> None: | |||
| """Polymarket tools — no ENV key needed, Gamma API is public.""" | |||
| async def get_markets(category: str = None, limit: int = 20) -> list: | |||
| """Get active prediction markets, optional category filter.""" | |||
| return await polymarket.get_markets(category=category, limit=limit) | |||
| async def trending_markets(limit: int = 10) -> list: | |||
| """Get top trending markets by trading volume.""" | |||
| return await polymarket.trending_markets(limit=limit) | |||
| async def analyze_market(market_id: str) -> dict: | |||
| """LLM analysis of a single market. Fallback if no LLM key set.""" | |||
| return await polymarket.analyze_market(market_id) | |||
| async def summary_report(category: str = None) -> dict: | |||
| """Summary report for a category or all markets.""" | |||
| return await polymarket.summary_report(category=category) | |||
| async def polymarket_cache_info() -> dict: | |||
| """Cache status, available categories, LLM availability.""" | |||
| return await polymarket.get_cache_info() | |||
| logger.info("Tools registered: polymarket (5 tools)") | |||
| # ============================================================================= | |||
| # Direct execution guard | |||
| # ============================================================================= | |||
| if __name__ == '__main__': | |||
| print("WARNING: Run via main.py, not directly.") | |||