| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import logging |
| from typing import Dict, Any |
| from . import config as app_config |
| from . import providers |
| from . import models |
| from . import tools |
|
|
| logger = logging.getLogger('mcp') |
|
|
| |
| |
| |
| _mcp = None |
| _transport = None |
| _stateless = None |
|
|
| |
| |
| |
| async def initialize() -> None: |
| """ |
| Initializes the MCP instance and registers all tools. |
| Called once by app/app.py during startup sequence. |
| No fundaments passed in β fully sandboxed. |
| |
| Reads HUB_TRANSPORT and HUB_STATELESS from .pyfun [HUB]. |
| |
| Transport modes: |
| streamable-http β get_asgi_app() returns ASGI app β app.py mounts it |
| sse β handle_request() used by Quart route in app.py |
| |
| Registration order: |
| 1. LLM tools β via tools.py + providers.py (key-gated) |
| 2. Search tools β via tools.py + providers.py (key-gated) |
| 3. System tools β always registered, no key required |
| 4. DB tools β uncomment when db_sync.py is ready |
| """ |
| global _mcp, _transport, _stateless |
|
|
| hub_cfg = app_config.get_hub() |
| _transport = hub_cfg.get("HUB_TRANSPORT", "streamable-http").lower() |
| _stateless = hub_cfg.get("HUB_STATELESS", "true").lower() == "true" |
|
|
| logger.info(f"MCP Hub initializing (transport: {_transport}, stateless: {_stateless})...") |
|
|
| 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." |
| ), |
| stateless_http=_stateless, |
| ) |
|
|
| |
| providers.initialize() |
| models.initialize() |
| tools.initialize() |
|
|
| |
| _register_llm_tools(_mcp) |
| _register_search_tools(_mcp) |
| _register_system_tools(_mcp) |
| |
|
|
| logger.info(f"MCP Hub initialized. Transport: {_transport}") |
|
|
|
|
| |
| |
| |
| def get_asgi_app(): |
| """ |
| Returns the ASGI app for the configured transport. |
| Called by app/app.py AFTER initialize() β mounted as ASGI sub-app. |
| |
| Streamable HTTP: mounts on /mcp β single endpoint for all MCP traffic. |
| SSE (fallback): returns sse_app() for legacy client compatibility. |
| |
| NOTE: For SSE transport, app/app.py uses the Quart route + handle_request() |
| instead β get_asgi_app() is only called for streamable-http. |
| """ |
| if _mcp is None: |
| raise RuntimeError("MCP not initialized β call initialize() first.") |
|
|
| if _transport == "streamable-http": |
| logger.info("MCP ASGI app: Streamable HTTP β /mcp") |
| return _mcp.streamable_http_app() |
| else: |
| |
| |
| logger.info("MCP ASGI app: SSE (legacy) β /sse") |
| return _mcp.sse_app() |
|
|
|
|
| |
| |
| |
| async def handle_request(request) -> None: |
| """ |
| Handles incoming MCP SSE requests via Quart /mcp route. |
| Only active when HUB_TRANSPORT = "sse" in .pyfun [HUB]. |
| |
| For Streamable HTTP transport this function is NOT called β |
| app/app.py mounts the ASGI app from get_asgi_app() directly. |
| |
| Interceptor point for SSE traffic: |
| Add auth, rate limiting, logging here before reaching MCP. |
| """ |
| if _mcp is None: |
| logger.error("MCP not initialized β call initialize() first.") |
| from quart import jsonify |
| return jsonify({"error": "MCP not initialized"}), 503 |
|
|
| |
| |
| |
| |
| |
|
|
| return await _mcp.handle_sse(request) |
|
|
|
|
| |
| |
| |
|
|
| def _register_llm_tools(mcp) -> None: |
| """ |
| Register LLM completion tool. |
| All logic delegated to tools.py β providers.py. |
| Adding a new LLM provider = update .pyfun + providers.py. Never touch this. |
| """ |
| if not providers.list_active_llm(): |
| logger.info("No active LLM providers β llm_complete tool skipped.") |
| return |
|
|
| @mcp.tool() |
| async def llm_complete( |
| prompt: str, |
| provider: str = None, |
| model: str = None, |
| max_tokens: int = 1024, |
| ) -> str: |
| """ |
| Send a prompt to any configured LLM provider. |
| Automatically follows the fallback chain defined in .pyfun if a provider fails. |
| |
| Args: |
| prompt: The input text to send to the model. |
| provider: Provider name (e.g. 'anthropic', 'gemini', 'openrouter', 'huggingface'). |
| Defaults to default_provider from .pyfun [TOOL.llm_complete]. |
| model: Model name override. Defaults to provider's default_model in .pyfun. |
| max_tokens: Maximum tokens in the response. Default: 1024. |
| |
| Returns: |
| Model response as plain text string. |
| """ |
| return await tools.run( |
| tool_name="llm_complete", |
| prompt=prompt, |
| provider_name=provider, |
| model=model, |
| max_tokens=max_tokens, |
| ) |
|
|
| logger.info(f"Tool registered: llm_complete (active providers: {providers.list_active_llm()})") |
|
|
|
|
| def _register_search_tools(mcp) -> None: |
| """ |
| Register web search tool. |
| All logic delegated to tools.py β providers.py. |
| Adding a new search provider = update .pyfun + providers.py. Never touch this. |
| """ |
| if not providers.list_active_search(): |
| logger.info("No active search providers β web_search tool skipped.") |
| return |
|
|
| @mcp.tool() |
| async def web_search( |
| query: str, |
| provider: str = None, |
| max_results: int = 5, |
| ) -> str: |
| """ |
| Search the web via any configured search provider. |
| Automatically follows the fallback chain defined in .pyfun if a provider fails. |
| |
| Args: |
| query: Search query string. |
| provider: Provider name (e.g. 'brave', 'tavily'). |
| Defaults to default_provider from .pyfun [TOOL.web_search]. |
| max_results: Maximum number of results to return. Default: 5. |
| |
| Returns: |
| Formatted search results as plain text string. |
| """ |
| return await tools.run( |
| tool_name="web_search", |
| prompt=query, |
| provider_name=provider, |
| max_results=max_results, |
| ) |
|
|
| logger.info(f"Tool registered: web_search (active providers: {providers.list_active_search()})") |
|
|
|
|
| def _register_system_tools(mcp) -> None: |
| """ |
| System tools β always registered, no ENV key required. |
| Exposes hub status and model info without touching secrets. |
| """ |
|
|
| @mcp.tool() |
| def list_active_tools() -> Dict[str, Any]: |
| """ |
| List all active providers and registered tools. |
| Shows ENV key names only β never exposes values or secrets. |
| |
| Returns: |
| Dict with hub info, active LLM providers, active search providers, |
| available tools and model names. |
| """ |
| hub = app_config.get_hub() |
| return { |
| "hub": hub.get("HUB_NAME", "Universal MCP Hub"), |
| "version": hub.get("HUB_VERSION", ""), |
| "transport": _transport, |
| "active_llm_providers": providers.list_active_llm(), |
| "active_search_providers": providers.list_active_search(), |
| "active_tools": tools.list_all(), |
| "available_models": models.list_all(), |
| } |
|
|
| logger.info("Tool registered: list_active_tools") |
|
|
| @mcp.tool() |
| def health_check() -> Dict[str, str]: |
| """ |
| Health check endpoint for HuggingFace Spaces and monitoring systems. |
| |
| Returns: |
| Dict with service status and active transport. |
| """ |
| return { |
| "status": "ok", |
| "service": "Universal MCP Hub", |
| "transport": _transport, |
| } |
|
|
| logger.info("Tool registered: health_check") |
|
|
| @mcp.tool() |
| def get_model_info(model_name: str) -> Dict[str, Any]: |
| """ |
| Get limits, costs, and capabilities for a specific model. |
| |
| Args: |
| model_name: Model name as defined in .pyfun [MODELS] (e.g. 'claude-sonnet-4-6'). |
| |
| Returns: |
| Dict with context size, max output tokens, rate limits, costs, and capabilities. |
| Returns empty dict if model is not configured in .pyfun. |
| """ |
| return models.get(model_name) |
|
|
| logger.info("Tool registered: get_model_info") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| if __name__ == '__main__': |
| print("WARNING: Run via main.py β app.py, not directly.") |
|
|