diff --git a/opax-mcp/server.py b/opax-mcp/server.py index d9ff219..4b3f357 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -1,40 +1,56 @@ -"""opax-mcp — MCP tool server for OPAX/Vauco -Transport: HTTP (FastAPI + uvicorn) for Cloud Run -Auth: Cloud Run IAM (Authorization header) + X-MCP-Secret header for tool-level auth +"""opax-mcp — MCP Streamable HTTP server for OPAX/Vauco +Transport: MCP Streamable HTTP (JSON-RPC 2.0) on POST / +Auth: Authorization: Bearer OR X-MCP-Secret: OR api-key: """ import os +import json +import uuid import httpx import base64 import google.auth import google.auth.transport.requests -from fastapi import FastAPI, HTTPException, Header -from pydantic import BaseModel -from typing import Optional, Any +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse +from typing import Any, Optional -app = FastAPI(title="opax-mcp", version="2.0.0") - -OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no") -OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "") -MCP_SECRET = os.environ.get("MCP_SECRET", "") - -# Gitea -GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000") -GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") -GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco") +app = FastAPI(title="opax-mcp", version="3.0.0") +OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no") +OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "") +MCP_SECRET = os.environ.get("MCP_SECRET", "") +GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco") GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") -def _auth_check(x_mcp_secret: Optional[str]): - if MCP_SECRET and x_mcp_secret != MCP_SECRET: - raise HTTPException(status_code=401, detail="Unauthorized") +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- +def _verify_auth(request: Request): + if not MCP_SECRET: + return + # Authorization: Bearer + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer ") and auth[7:] == MCP_SECRET: + return + # X-MCP-Secret or api-key + if request.headers.get("X-MCP-Secret") == MCP_SECRET: + return + if request.headers.get("api-key") == MCP_SECRET: + return + raise HTTPException(status_code=401, detail="Unauthorized") + + +# --------------------------------------------------------------------------- +# OPAX helpers +# --------------------------------------------------------------------------- def _opax_identity_token() -> str: - """Hent identity token for opax.vauco.no (Cloud Run IAP/IAM).""" metadata_url = ( "http://metadata.google.internal/computeMetadata/v1/instance" - f"/service-accounts/default/identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full" + f"/service-accounts/default/identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full" ) try: resp = httpx.get(metadata_url, headers={"Metadata-Flavor": "Google"}, timeout=5) @@ -48,106 +64,54 @@ def _opax_identity_token() -> str: def _opax_headers() -> dict: - return { - "Authorization": f"Bearer {_opax_identity_token()}", - "Content-Type": "application/json", - } + return {"Authorization": f"Bearer {_opax_identity_token()}", "Content-Type": "application/json"} -# --------------------------------------------------------------------------- -# Health -# --------------------------------------------------------------------------- - -@app.get("/health") -async def health(): - return {"status": "ok", "service": "opax-mcp", "version": "2.0.0", "git": "gitea"} - - -# --------------------------------------------------------------------------- -# Tool request/response schema -# --------------------------------------------------------------------------- - -class ToolRequest(BaseModel): - tool: str - params: dict = {} - - -@app.post("/tools/call") -async def call_tool( - req: ToolRequest, - x_mcp_secret: Optional[str] = Header(default=None, alias="X-MCP-Secret"), - api_key: Optional[str] = Header(default=None, alias="api-key") -): - _auth_check(x_mcp_secret or api_key) - handler = TOOLS.get(req.tool) - if not handler: - raise HTTPException(status_code=404, detail=f"Unknown tool: {req.tool}") - result = await handler(req.params) - return {"tool": req.tool, "result": result} - - -@app.get("/tools") -async def list_tools( - x_mcp_secret: Optional[str] = Header(default=None, alias="X-MCP-Secret"), - api_key: Optional[str] = Header(default=None, alias="api-key") -): - _auth_check(x_mcp_secret or api_key) - return {"tools": list(TOOLS.keys())} - - -# --------------------------------------------------------------------------- -# Helpers — OPAX -# --------------------------------------------------------------------------- - async def _opax_get(path: str) -> Any: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers()) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers()) r.raise_for_status() return r.json() async def _opax_post(path: str, body: dict) -> Any: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post(f"{OPAX_BASE_URL}{path}", json=body, headers=_opax_headers()) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{OPAX_BASE_URL}{path}", json=body, headers=_opax_headers()) r.raise_for_status() return r.json() # --------------------------------------------------------------------------- -# Helpers — Gitea +# Gitea helpers # --------------------------------------------------------------------------- def _gitea_headers() -> dict: - return { - "Authorization": f"token {GITEA_TOKEN}", - "Content-Type": "application/json", - "Accept": "application/json", - } + return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json", "Accept": "application/json"} async def _gitea_get(path: str) -> Any: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers()) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers()) r.raise_for_status() return r.json() async def _gitea_post(path: str, body: dict) -> Any: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) r.raise_for_status() return r.json() async def _gitea_put(path: str, body: dict) -> Any: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) + async with httpx.AsyncClient(timeout=30) as c: + r = await c.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) r.raise_for_status() return r.json() # --------------------------------------------------------------------------- -# Billing tools +# Tool implementations # --------------------------------------------------------------------------- async def get_billing_summary(p): return await _opax_get("/billing/summary") @@ -157,82 +121,36 @@ async def get_billing_anomalies(p): return await _opax_get("/billing/anom async def get_billing_history(p): return await _opax_get("/billing/history") async def get_billing_budget(p): return await _opax_get("/billing/budget") async def get_billing_tokens_by_module(p): return await _opax_get("/telemetry/history") - async def set_billing_budget(p): - return await _opax_post("/billing/budget", { - "budget": p.get("amount", 500) - }) - - -# --------------------------------------------------------------------------- -# Onboarding tools -# --------------------------------------------------------------------------- + return await _opax_post("/billing/budget", {"budget": p.get("amount", 500)}) async def create_invite(p): return await _opax_post("/onboard/invite", { "company": p.get("company", p.get("name", "")), "email": p.get("email"), - "tier": p.get("tier", "starter") + "tier": p.get("tier", "starter"), }) -async def list_customers(p): - """Hent onboardede kunder frå Firestore via OPAX state.""" - return await _opax_get("/state") - - -# --------------------------------------------------------------------------- -# Notify tools -# --------------------------------------------------------------------------- - +async def list_customers(p): return await _opax_get("/state") async def send_webhook(p): return await _opax_post("/notify/webhook", { - "url": p.get("url", ""), - "event": p.get("event", "custom"), - "title": p.get("title", "OPAX varsel"), - "body": p.get("message", p.get("body", "")), + "url": p.get("url", ""), "event": p.get("event", "custom"), + "title": p.get("title", "OPAX varsel"), "body": p.get("message", p.get("body", "")), }) async def send_email(p): - return await _opax_post("/notify/email", { - "to": p.get("to"), - "subject": p.get("subject"), - "event": p.get("event", "digest"), - }) + return await _opax_post("/notify/email", {"to": p.get("to"), "subject": p.get("subject"), "event": p.get("event", "digest")}) async def send_sms(p): - return await _opax_post("/notify/sms", { - "to": p.get("to"), - "body": p.get("message", p.get("body", "")), - "event": p.get("event", "spike"), - "tier": p.get("tier", "guard"), - }) + return await _opax_post("/notify/sms", {"to": p.get("to"), "body": p.get("message", p.get("body", "")), "event": p.get("event", "spike"), "tier": p.get("tier", "guard")}) -async def get_notify_channels(p): - return await _opax_get("/notify/channels") - - -# --------------------------------------------------------------------------- -# Agent tools -# --------------------------------------------------------------------------- +async def get_notify_channels(p): return await _opax_get("/notify/channels") async def run_jason(p): - return await _opax_post("/run", { - "message": p.get("prompt", p.get("message", "")), - "user_id": p.get("user_id", "opax"), - "session_id": p.get("session_id", "mcp"), - "mode": p.get("mode", "light"), - }) + return await _opax_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")}) async def run_emma(p): - return await _opax_post("/emma", { - "message": p.get("prompt", p.get("message", "")), - "session_id": p.get("session_id", "mcp"), - }) - - -# --------------------------------------------------------------------------- -# Platform tools -# --------------------------------------------------------------------------- + return await _opax_post("/emma", {"message": p.get("prompt", p.get("message", "")), "session_id": p.get("session_id", "mcp")}) async def get_health(p): return await _opax_get("/health") async def get_build_status(p): return await _opax_get("/opax/build-status") @@ -240,47 +158,25 @@ async def get_state(p): return await _opax_get("/state") async def get_telemetry(p): return await _opax_get("/telemetry/history") async def run_terminal(p): - return await _opax_post("/terminal/exec", { - "cmd": p.get("command", p.get("cmd", "help")) - }) - - -# --------------------------------------------------------------------------- -# Gitea tools (erstatter GitHub) -# --------------------------------------------------------------------------- + return await _opax_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))}) async def list_commits(p): - repo = p.get("repo", GITEA_REPO) - limit = p.get("limit", 10) - return await _gitea_get(f"/repos/{repo}/commits?limit={limit}") + return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/commits?limit={p.get('limit', 10)}") async def get_file(p): - repo = p.get("repo", GITEA_REPO) - path = p.get("path", "") - ref = p.get("ref", "main") - return await _gitea_get(f"/repos/{repo}/contents/{path}?ref={ref}") + return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/contents/{p.get('path', '')}?ref={p.get('ref', 'main')}") async def list_open_issues(p): - repo = p.get("repo", GITEA_REPO) - return await _gitea_get(f"/repos/{repo}/issues?state=open&limit=20") + return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/issues?state=open&limit=20") async def create_issue(p): - repo = p.get("repo", GITEA_REPO) - return await _gitea_post(f"/repos/{repo}/issues", { - "title": p.get("title"), - "body": p.get("body", ""), - }) + return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")}) async def push_file(p): - """Opprett eller oppdater ein fil i Gitea.""" repo = p.get("repo", GITEA_REPO) path = p.get("path") content = base64.b64encode(p.get("content", "").encode()).decode() - body = { - "message": p.get("message", f"chore: oppdater {path} via opax-mcp"), - "content": content, - "branch": p.get("branch", "main"), - } + body = {"message": p.get("message", f"chore: oppdater {path} via opax-mcp"), "content": content, "branch": p.get("branch", "main")} if p.get("sha"): body["sha"] = p["sha"] return await _gitea_put(f"/repos/{repo}/contents/{path}", body) @@ -288,40 +184,117 @@ async def push_file(p): # --------------------------------------------------------------------------- -# Tool registry +# Tool registry + MCP schema # --------------------------------------------------------------------------- TOOLS = { - # Billing - "get_billing_summary": get_billing_summary, - "get_billing_forecast": get_billing_forecast, - "get_billing_credits": get_billing_credits, - "get_billing_anomalies": get_billing_anomalies, - "get_billing_history": get_billing_history, - "get_billing_budget": get_billing_budget, - "set_billing_budget": set_billing_budget, - "get_billing_tokens_by_module": get_billing_tokens_by_module, - # Onboarding - "create_invite": create_invite, - "list_customers": list_customers, - # Notify - "send_webhook": send_webhook, - "send_email": send_email, - "send_sms": send_sms, - "get_notify_channels": get_notify_channels, - # Agents - "run_jason": run_jason, - "run_emma": run_emma, - # Platform - "get_health": get_health, - "get_build_status": get_build_status, - "get_state": get_state, - "get_telemetry": get_telemetry, - "run_terminal": run_terminal, - # Gitea (erstatter GitHub) - "list_commits": list_commits, - "get_file": get_file, - "list_open_issues": list_open_issues, - "create_issue": create_issue, - "push_file": push_file, + "get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}), + "get_billing_forecast": (get_billing_forecast, "Hent billing-prognose", {}), + "get_billing_credits": (get_billing_credits, "Hent gjenværende kreditter", {}), + "get_billing_anomalies": (get_billing_anomalies, "Hent billing-anomalier", {}), + "get_billing_history": (get_billing_history, "Hent billing-historikk", {}), + "get_billing_budget": (get_billing_budget, "Hent nåværende budsjett", {}), + "get_billing_tokens_by_module":(get_billing_tokens_by_module,"Hent token-forbruk per modul", {}), + "set_billing_budget": (set_billing_budget, "Sett månedlig budsjett", {"type":"object","properties":{"amount":{"type":"number","description":"Budsjett i NOK"}},"required":[]}), + "create_invite": (create_invite, "Inviter ny kunde til OPAX", {"type":"object","properties":{"company":{"type":"string"},"email":{"type":"string"},"tier":{"type":"string"}},"required":["email"]}), + "list_customers": (list_customers, "List alle kunder", {}), + "send_webhook": (send_webhook, "Send webhook-varsling", {"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"}},"required":["url"]}), + "send_email": (send_email, "Send e-post", {"type":"object","properties":{"to":{"type":"string"},"subject":{"type":"string"}},"required":["to","subject"]}), + "send_sms": (send_sms, "Send SMS", {"type":"object","properties":{"to":{"type":"string"},"message":{"type":"string"}},"required":["to","message"]}), + "get_notify_channels": (get_notify_channels, "Hent varslingkanaler", {}), + "run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}), + "run_emma": (run_emma, "Kjør Emma-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}), + "get_health": (get_health, "Hent helsestatus for OPAX", {}), + "get_build_status": (get_build_status, "Hent siste build-status", {}), + "get_state": (get_state, "Hent platform-tilstand", {}), + "get_telemetry": (get_telemetry, "Hent telemetri-historikk", {}), + "run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}), + "list_commits": (list_commits, "List siste commits i Gitea-repo", {}), + "get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}), + "list_open_issues": (list_open_issues, "List åpne issues i Gitea-repo", {}), + "create_issue": (create_issue, "Opprett nytt issue i Gitea-repo", {"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"}},"required":["title"]}), + "push_file": (push_file, "Opprett eller oppdater fil i Gitea-repo", {"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"},"message":{"type":"string"}},"required":["path","content"]}), } + + +def _tool_list_result(): + return [ + { + "name": name, + "description": desc, + "inputSchema": schema if schema else {"type": "object", "properties": {}}, + } + for name, (_, desc, schema) in TOOLS.items() + ] + + +def _jsonrpc_ok(req_id, result): + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def _jsonrpc_err(req_id, code, message): + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +# --------------------------------------------------------------------------- +# MCP Streamable HTTP endpoint — POST / +# --------------------------------------------------------------------------- + +@app.post("/") +async def mcp_handler(request: Request): + _verify_auth(request) + + try: + body = await request.json() + except Exception: + return JSONResponse(_jsonrpc_err(None, -32700, "Parse error"), status_code=400) + + method = body.get("method", "") + req_id = body.get("id") + params = body.get("params", {}) + + # initialize + if method == "initialize": + return JSONResponse(_jsonrpc_ok(req_id, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "opax-mcp", "version": "3.0.0"}, + })) + + # tools/list + if method == "tools/list": + return JSONResponse(_jsonrpc_ok(req_id, {"tools": _tool_list_result()})) + + # tools/call + if method == "tools/call": + tool_name = params.get("name") or params.get("tool") + tool_args = params.get("arguments", params.get("params", {})) + + entry = TOOLS.get(tool_name) + if not entry: + return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}")) + + handler, _, _ = entry + try: + result = await handler(tool_args) + except Exception as e: + return JSONResponse(_jsonrpc_err(req_id, -32000, str(e))) + + return JSONResponse(_jsonrpc_ok(req_id, { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}] + })) + + # notifications (fire-and-forget, no response needed) + if method.startswith("notifications/"): + return JSONResponse(status_code=202, content={}) + + return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404) + + +# --------------------------------------------------------------------------- +# Health (keepalive) +# --------------------------------------------------------------------------- + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "opax-mcp", "version": "3.0.0"}