OSVauco/opax-mcp/server.py

311 lines
11 KiB
Python

"""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
"""
import os
import httpx
import base64
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from typing import Optional, Any
app = FastAPI(title="opax-mcp", version="2.0.0")
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
IAP_CLIENT_ID = os.environ.get("IAP_CLIENT_ID", "357036551735-kq8nt7ld38hfqlcfb3n52ef7tala4meo.apps.googleusercontent.com")
async def _iap_token() -> str:
"""Fetch IAP identity token from GCP metadata server."""
url = (f"http://metadata.google.internal/computeMetadata/v1/instance/"
f"service-accounts/default/identity?audience={IAP_CLIENT_ID}&format=full")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url, headers={"Metadata-Flavor": "Google"})
r.raise_for_status()
return r.text.strip()
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")
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")
# ---------------------------------------------------------------------------
# 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)
):
_auth_check(x_mcp_secret)
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)):
_auth_check(x_mcp_secret)
return {"tools": list(TOOLS.keys())}
# ---------------------------------------------------------------------------
# Helpers — OPAX
# ---------------------------------------------------------------------------
async def _opax_get(path: str) -> Any:
token = await _iap_token()
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{OPAX_BASE_URL}{path}",
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
return r.json()
async def _opax_post(path: str, body: dict) -> Any:
token = await _iap_token()
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{OPAX_BASE_URL}{path}", json=body,
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# Helpers — Gitea
# ---------------------------------------------------------------------------
def _gitea_headers() -> dict:
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())
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())
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())
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# Billing tools
# ---------------------------------------------------------------------------
async def get_billing_summary(p): return await _opax_get("/billing/summary")
async def get_billing_forecast(p): return await _opax_get("/billing/forecast")
async def get_billing_credits(p): return await _opax_get("/billing/credits")
async def get_billing_anomalies(p): return await _opax_get("/billing/anomalies")
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
# ---------------------------------------------------------------------------
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")
})
async def list_customers(p):
"""Hent onboardede kunder frå Firestore via OPAX state."""
return await _opax_get("/state")
# ---------------------------------------------------------------------------
# Notify tools
# ---------------------------------------------------------------------------
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", "")),
})
async def send_email(p):
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"),
})
async def get_notify_channels(p):
return await _opax_get("/notify/channels")
# ---------------------------------------------------------------------------
# Agent tools
# ---------------------------------------------------------------------------
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"),
})
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
# ---------------------------------------------------------------------------
async def get_health(p): return await _opax_get("/health")
async def get_build_status(p): return await _opax_get("/opax/build-status")
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)
# ---------------------------------------------------------------------------
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}")
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}")
async def list_open_issues(p):
repo = p.get("repo", GITEA_REPO)
return await _gitea_get(f"/repos/{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", ""),
})
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"),
}
if p.get("sha"):
body["sha"] = p["sha"]
return await _gitea_put(f"/repos/{repo}/contents/{path}", body)
return await _gitea_post(f"/repos/{repo}/contents/{path}", body)
# ---------------------------------------------------------------------------
# Tool registry
# ---------------------------------------------------------------------------
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,
}