File size: 18,758 Bytes
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ef3828
85a0eea
 
 
5ef3828
85a0eea
5ef3828
85a0eea
 
 
 
 
5ef3828
85a0eea
 
 
5ef3828
85a0eea
 
5ef3828
 
 
 
 
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b296b2
 
 
 
 
5ef3828
9b296b2
5ef3828
 
 
 
 
 
 
9b296b2
 
 
5ef3828
 
 
 
 
 
 
 
 
 
9b296b2
 
85a0eea
 
 
 
5ef3828
85a0eea
 
 
 
 
 
 
 
 
5ef3828
85a0eea
 
 
 
 
 
 
 
 
5ef3828
 
 
 
aec9b35
5ef3828
aec9b35
5ef3828
 
 
 
 
 
 
1e2a207
1bb47d3
 
 
 
 
 
 
5ef3828
1bb47d3
 
 
5ef3828
1bb47d3
c9d4ca5
 
5ef3828
 
 
c9d4ca5
 
 
5ef3828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e2a207
 
 
 
5a09c18
aec9b35
1bb47d3
5ef3828
 
 
 
 
 
 
 
aec9b35
5ef3828
 
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
5ef3828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ef3828
85a0eea
 
5ef3828
d7a2bef
948a289
 
d7a2bef
948a289
 
d7a2bef
948a289
 
d7a2bef
5ef3828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85a0eea
 
5ef3828
 
 
 
9b296b2
5ef3828
 
 
9b296b2
5ef3828
 
 
 
 
 
9b296b2
5ef3828
 
 
85a0eea
 
 
5ef3828
85a0eea
5ef3828
85a0eea
 
5ef3828
 
 
 
 
 
85a0eea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b296b2
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
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# =============================================================================
# root/app/app.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 is the Orchestrator of the sandboxed app/* layer.
#   It is ONLY started by main.py (the "Guardian").
#   All fundament services are injected via the `fundaments` dictionary.
#   Direct execution is blocked by design.
#
# SANDBOX RULES:
#   - fundaments dict is ONLY unpacked inside start_application()
#   - fundaments are NEVER stored globally or passed to other app/* modules
#   - app/* modules read their own config from app/.pyfun
#   - app/* internal state/IPC uses app/db_sync.py (SQLite) β€” NOT postgresql.py
#   - Secrets stay in .env β†’ Guardian reads them β†’ never touched by app/*
# =============================================================================
from quart import Quart, request, jsonify  # async Flask β€” ASGI compatible
import json
import logging
from hypercorn.asyncio import serve        # ASGI server β€” async native, replaces waitress
from hypercorn.config import Config        # hypercorn config
from datetime import datetime, timezone
import asyncio
from typing import Dict, Any
# =============================================================================
# Import app/* modules β€” MINIMAL BUILD
# Each module reads its own config from app/.pyfun independently.
# NO fundaments passed into these modules!
# =============================================================================
from . import mcp                   # MCP transport layer (Streamable HTTP / SSE via ASGI mount)
from . import config as app_config  # app/.pyfun parser β€” used only in app/*
from . import providers    # API provider registry β€” reads app/.pyfun
from . import models       # Model config + token/rate limits β€” reads app/.pyfun
from . import pricing      # Auto model costs (OpenRouter + LiteLLM) β€” reads app/.pyfun
from . import tools        # MCP tool definitions + provider mapping β€” reads app/.pyfun
from . import db_sync      # Internal SQLite IPC β€” app/* state & communication
from . import shellmaster  # ShellMaster 2.0 β€” safe shell assistant (inactive by default)
from . import auth         # Optional shared-token gate for /api, /crypto, /mcp (off without token)
from . import discord_api  # Discord: outbound post + interactions endpoint (inactive by default)
from . import gsc          # Google Search Console tool (inactive by default, optional libs)
from . import skills_db    # Skill databases app/db/skills/ (read-only; BM25)
#                          # db_sync β‰  postgresql.py! Cloud DB is Guardian-only.
# Future modules (will uncomment when ready):
# from . import discord_api  # Discord bot integration
# from . import hf_hooks     # HuggingFace Space hooks
# from . import git_hooks    # GitHub/GitLab webhook handler
# from . import web_api      # Generic REST API handler
# =============================================================================
# Loggers β€” one per module for clean log filtering
# =============================================================================
logger        = logging.getLogger('application')
logger_mcp    = logging.getLogger('mcp')
logger_config = logging.getLogger('config')
logger_tools     = logging.getLogger('tools')
logger_providers = logging.getLogger('providers')
logger_models    = logging.getLogger('models')
logger_db_sync   = logging.getLogger('db_sync')



# ── NEU: nach den Imports, vor app = Quart(__name__) ──────────────────────────

def _make_mount_middleware(outer_app, prefixes, inner_app, strip_prefix: bool = False):
    """
    ASGI-Middleware: leitet Requests mit passendem Prefix an inner_app
    (FastMCP Streamable HTTP / SSE) weiter, alles andere geht an outer_app (Quart).
    Lifespan-Scopes gehen immer an outer_app (Quart).

    WICHTIG: Prefix wird standardmaessig NICHT gestript - FastMCP registriert
    seine Route intern selbst (settings.streamable_http_path = "/mcp").
    Wer stript, erzeugt 404s. Genau das war der Break im alten Build.
    """
    async def middleware(scope, receive, send):
        path = scope.get("path", "")
        for prefix in prefixes:
            if path == prefix or path.startswith(prefix + "/"):
                if strip_prefix:
                    scope = dict(scope)
                    stripped = path[len(prefix):] or "/"
                    scope["path"] = stripped
                    scope["raw_path"] = stripped.encode()
                await inner_app(scope, receive, send)
                return
        await outer_app(scope, receive, send)
    return middleware

# =============================================================================
# Quart app instance
# =============================================================================
app = Quart(__name__)
START_TIME = datetime.now(timezone.utc)
# =============================================================================
# Quart Routes
# =============================================================================
@app.route("/", methods=["GET"])
async def health_check():
    """
    Health check endpoint.
    Used by HuggingFace Spaces and monitoring systems to verify the app is running.
    """
    uptime = datetime.now(timezone.utc) - START_TIME
    return jsonify({
        "status": "running",
        "service": "Universal MCP Hub",
        "uptime_seconds": int(uptime.total_seconds()),
    })


@app.route("/api", methods=["POST"])
async def api_endpoint():
    """
    REST tool gateway: POST {"tool": "name", "params": {...}}
    Auth: optional shared token β€” see app/auth.py (.pyfun [HUB] HUB_AUTH_ENV).
    """
    try:
        data      = await request.get_json(silent=True) or {}
        tool_name = data.get("tool")
        params    = data.get("params", {}) or {}

        if not tool_name:
            return jsonify({"error": "Missing 'tool'"}), 400
        if not isinstance(params, dict):
            return jsonify({"error": "'params' must be an object"}), 400
        params.pop("tool_name", None)  # would collide with tools.run(tool_name, ...) β†’ 500

        # System tools β€” handle directly, no prompt needed!
        if tool_name == "list_active_tools":
            return jsonify({"result": {
                "active_tools":            tools.list_all(),
                "active_llm_providers":    providers.list_active_llm(),
                "active_search_providers": providers.list_active_search(),
                "available_models":        models.list_all(),
                "pricing_stats":           pricing.stats(),
            }})

        if tool_name == "health_check":
            return jsonify({"result": await tools.health()})

        # db_query β€” handled by db_sync directly, not tools.run()
        if tool_name == "db_query":
            sql = params.get("sql", "") or params.get("prompt", "")
            if not sql:
                return jsonify({"error": "db_query requires 'sql' (or 'prompt') parameter"}), 400
            result = await db_sync.query(sql)
            return jsonify({"result": result})

        # persist_status β€” memory orchestration observability (no prompt needed)
        if tool_name == "persist_status":
            return jsonify({"result": await db_sync.persist_queue_stats()})

        # usage_stats β€” tokens + estimated cost per model/provider/tool/day
        if tool_name == "usage_stats":
            result = await db_sync.usage_stats(
                group_by=str(params.get("group_by", "model")),
                days=int(params.get("days") or 0) or None,
            )
            return jsonify({"result": result})

        # openrouter_catalog β€” real model IDs for [MODELS]
        if tool_name == "openrouter_catalog":
            return jsonify({"result": await pricing.catalog(
                query=str(params.get("query", "")),
                free_only=bool(params.get("free_only", True)),
                limit=int(params.get("limit") or 40),
            )})

        # get_model_pricing β€” auto-fetched costs, .pyfun values win as override
        if tool_name == "get_model_pricing":
            model_name = params.get("model_name", "") or params.get("model", "")
            if not model_name:
                return jsonify({"error": "get_model_pricing requires 'model_name'"}), 400
            result = await pricing.get_pricing(model_name)
            return jsonify({"result": result})

        # rename 'provider' β†’ 'provider_name' for tools.run()
        if "provider" in params:
            params["provider_name"] = params.pop("provider")

        result = await tools.run(tool_name, **params)
        return jsonify({"result": result})

    except ValueError as e:                    # unknown tool / missing prompt β†’ 400
        return jsonify({"error": str(e)}), 400
    except KeyError as e:
        return jsonify({"error": str(e.args[0])}), 404
    except RuntimeError as e:
        # Provider-Kette erschΓΆpft β€” Message ist upstream sanitisiert
        # (nur HTTP-Status + safe_url, niemals Keys/Bodies)
        return jsonify({"error": str(e)}), 502
    except Exception as e:
        logger.error(f"API error: {type(e).__name__}: {e}")
        return jsonify({"error": "Internal error"}), 500


@app.route("/crypto", methods=["POST"])
async def crypto_endpoint():
    """
    Encrypted API endpoint.
    Encryption handled by app/* layer β€” no direct fundaments access here.
    """
    # TODO: implement via app/* encryption wrapper
    data = await request.get_json()
    return jsonify({"status": "not_implemented"}), 501


# Discord interactions endpoint. NOT behind the hub token gate (Discord cannot send it) β€”
# protected by Ed25519 signature verification instead (discord_api.verify_signature).
# Needs a PUBLIC URL (a private HF Space is unreachable for Discord).
@app.route("/discord", methods=["POST"])
async def discord_interactions():
    """Discord interactions endpoint β€” signature verified BEFORE the body is parsed."""
    if not discord_api.is_ready():
        return jsonify({"error": "discord not active"}), 404

    raw = await request.get_data()   # RAW bytes β€” re-serialized JSON would break the signature
    if not discord_api.verify_signature(
        raw,
        request.headers.get("X-Signature-Ed25519", ""),
        request.headers.get("X-Signature-Timestamp", ""),
    ):
        return jsonify({"error": "invalid request signature"}), 401

    try:
        payload = json.loads(raw)
    except ValueError:
        return jsonify({"error": "invalid JSON"}), 400

    response = await discord_api.handle_interaction(payload)
    if response is None:
        return jsonify({"error": "unsupported interaction"}), 400
    return jsonify(response)


# /ask <prompt> β€” Discord kills answers after 3 s, so: defer (type 5), run the tool in the
# background, then send_followup(). Strong refs keep the tasks from being garbage-collected.
_bg_tasks: set = set()


async def _discord_ask(interaction: dict) -> dict:
    cfg     = app_config.get_discord()
    guilds  = {g.strip() for g in str(cfg.get("DISCORD_ALLOWED_GUILDS", "")).split(",") if g.strip()}
    if guilds and str(interaction.get("guild_id", "")) not in guilds:
        return {"type": discord_api.RESPONSE_CHANNEL_MESSAGE, "data": {"content": "Not available on this server."}}

    opts   = {o.get("name"): o.get("value") for o in (interaction.get("data", {}).get("options") or [])}
    prompt = str(opts.get("prompt") or "").strip()
    if not prompt:
        return {"type": discord_api.RESPONSE_CHANNEL_MESSAGE, "data": {"content": "Usage: /ask prompt:<your question>"}}

    token = interaction.get("token", "")

    async def _answer():
        try:
            text = await tools.run("llm_complete", prompt=prompt, max_tokens=700)
        except Exception as e:
            logger.warning(f"Discord /ask failed: {type(e).__name__}")
            text = "Sorry β€” the hub could not answer right now."
        await discord_api.send_followup(token, text[:1990])   # Discord limit: 2000

    task = asyncio.create_task(_answer())
    _bg_tasks.add(task)
    task.add_done_callback(_bg_tasks.discard)
    return {"type": discord_api.RESPONSE_DEFERRED_CHANNEL_MESSAGE}

# @app.route("/webhook/hf", methods=["POST"])
# async def hf_webhook():
#     """HuggingFace Space event hooks."""
#     pass

# @app.route("/webhook/git", methods=["POST"])
# async def git_webhook():
#     """GitHub / GitLab webhook handler."""
#     pass
# =============================================================================
# Main entry point β€” called exclusively by Guardian (main.py)
# =============================================================================
async def start_application(fundaments: Dict[str, Any]) -> None:
    """
    Main entry point for the sandboxed app layer.
    Called exclusively by main.py after all fundament services are initialized.

    Args:
        fundaments: Dictionary of initialized services from Guardian (main.py).
                    Services are unpacked here and NEVER stored globally or
                    passed into other app/* modules.
    """
    logger.info("Application starting...")

    # =========================================================================
    # Unpack fundaments β€” ONLY here, NEVER elsewhere in app/*
    # These are the 6 fundament services from fundaments/*
    # =========================================================================
    config_service          = fundaments["config"]          # fundaments/config_handler.py
    db_service              = fundaments["db"]              # fundaments/postgresql.py β€” None if not configured
    encryption_service      = fundaments["encryption"]      # fundaments/encryption.py β€” None if keys not set
    access_control_service  = fundaments["access_control"]  # fundaments/access_control.py β€” None if no DB
    user_handler_service    = fundaments["user_handler"]    # fundaments/user_handler.py β€” None if no DB
    security_service        = fundaments["security"]        # fundaments/security.py β€” None if deps missing

    # --- Log active fundament services ---
    if encryption_service:
        logger.info("Encryption service active.")

    if user_handler_service and security_service:
        logger.info("Auth services active (user_handler + security).")

    if access_control_service and security_service:
        logger.info("Access control active.")

    if db_service and not user_handler_service:
        logger.info("Database-only mode active (e.g. ML pipeline).")

    if not db_service:
        logger.info("Database-free mode active (e.g. Discord bot, API client).")

    # =========================================================================
    # Initialize app/* internal services β€” MINIMAL BUILD
    # Each module reads its own config from app/.pyfun β€” no fundaments passed in.
    # =========================================================================

    # db_sync only if cloud_DB used to!
    # PSQL bridge β€” nur wenn Guardian DB-Service injiziert hat
    await db_sync.initialize()

    if db_service:
        # asyncpg Pool direkt nutzen β€” kein execute_secured_query nΓΆtig
        db_sync.set_psql_writer(db_service.execute)
        logger.info("PostgreSQL bridge active.")
    else:
        logger.info("PostgreSQL bridge inactive β€” no DATABASE_URL configured.")

    # Memory-Orchestrierung: Retry-Flusher starten β€” lΓ€uft MIT und OHNE PG.
    # Ohne Writer bleibt alles restart-safe in der SQLite-Queue liegen
    # (persist_result ohne DATABASE_URL = queued, kein Crash).
    db_sync.start_persist_flusher()

    # ShellMaster β€” bleibt inaktiv, bis .pyfun [TOOL.shellmaster] active="true"
    # UND token + commands-Datei vorhanden sind (siehe shellmaster.initialize)
    await shellmaster.initialize()

    # Integrationen β€” beide bleiben inaktiv, bis [DISCORD] / [GSC] in .pyfun aktiv sind
    await discord_api.initialize()
    discord_api.register_command_handler("ask", _discord_ask)
    gsc.initialize()
    skills_db.initialize()

    # Auto-Pricing β€” fΓΌllt Kosten aus OpenRouter/LiteLLM nach, .pyfun gewinnt.
    # LΓ€uft im Hintergrund β€” blockiert den Start NICHT (no fetch = no crash).
    await pricing.initialize()

    await mcp.initialize()

    # =========================================================================
    # MCP: ASGI-App holen, DANACH mounten, DANACH lifespan starten
    # (get_asgi_app() muss VOR mcp.lifespan() kommen β€” siehe mcp.py)
    # =========================================================================
    hub_cfg   = app_config.get_hub()
    mcp_asgi  = mcp.get_asgi_app()
    app.asgi_app = _make_mount_middleware(app.asgi_app, mcp.mount_prefixes(), mcp_asgi)
    logger.info(f"MCP mounted: {', '.join(mcp.mount_prefixes())}")

    # Optional token gate (outermost layer). No token configured = open, as before.
    app.asgi_app = auth.make_middleware(
        app.asgi_app,
        auth.token_from_env(hub_cfg.get("HUB_AUTH_ENV", "")),
        ("/api", "/crypto") + tuple(mcp.mount_prefixes()),
    )

    # --- Read HOST/PORT from app/.pyfun [HUB] ---
    host = hub_cfg.get("HUB_HOST", "0.0.0.0")
    port = int(hub_cfg.get("HUB_PORT", "7860"))  # hub_cfg bereits gelesen

    # --- Configure hypercorn ---
    config = Config()
    config.bind = [f"{host}:{port}"]

    logger.info(f"Starting hypercorn on {host}:{port}...")

    # --- Run hypercorn β€” blocks until shutdown ---
    # mcp.lifespan() ist PFLICHT: startet den FastMCP Session-Manager.
    # Ohne ihn schlΓ€gt jeder /mcp-Request mit
    # 'Task group is not initialized' fehl.
    async with mcp.lifespan():
        logger.info("All services running.")
        await serve(app, config)

# =============================================================================
# Direct execution guard
# =============================================================================
if __name__ == '__main__':
    print("WARNING: Running app.py directly. Fundament modules might not be correctly initialized.")
    print("Please run 'python main.py' instead for proper initialization.")

    test_fundaments = {
        "config":           None,
        "db":               None,
        "encryption":       None,
        "access_control":   None,
        "user_handler":     None,
        "security":         None,
    }

    asyncio.run(start_application(test_fundaments))