File size: 15,471 Bytes
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# =============================================================================
# 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")

            @mcp.tool()
            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")

            @mcp.tool()
            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")

            @mcp.tool()
            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", "https://api-inference.huggingface.co/models")
            _def_model = cfg.get("default_model", "mistralai/Mistral-7B-Instruct-v0.3")

            @mcp.tool()
            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"))

            @mcp.tool()
            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"

            @mcp.tool()
            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."""

    @mcp.tool()
    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")

    @mcp.tool()
    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."""

    @mcp.tool()
    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)

    @mcp.tool()
    async def trending_markets(limit: int = 10) -> list:
        """Get top trending markets by trading volume."""
        return await polymarket.trending_markets(limit=limit)

    @mcp.tool()
    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)

    @mcp.tool()
    async def summary_report(category: str = None) -> dict:
        """Summary report for a category or all markets."""
        return await polymarket.summary_report(category=category)

    @mcp.tool()
    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.")