fix(CI1): opax-mcp — bytt til uvicorn/FastAPI HTTP server, legg til /health

This commit is contained in:
chrischristiansen-glitch 2026-06-15 18:11:47 +02:00
parent 22f7774809
commit 27290b2ab6
3 changed files with 199 additions and 315 deletions

View File

@ -7,10 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# MCP over HTTP (SSE transport) for Cloud Run
ENV MCP_TRANSPORT=sse
ENV PORT=8080 ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
CMD ["python", "-m", "mcp", "run", "server.py", "--transport", "sse", "--port", "8080"] CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@ -1,4 +1,5 @@
mcp[cli]>=1.3.0 fastapi>=0.111.0
uvicorn>=0.30.0
httpx>=0.27.0 httpx>=0.27.0
google-auth>=2.29.0 google-auth>=2.29.0
google-cloud-firestore>=2.16.0 google-cloud-firestore>=2.16.0

View File

@ -1,357 +1,242 @@
#!/usr/bin/env python3 """opax-mcp — MCP tool server for OPAX/Vauco
Transport: HTTP (FastAPI + uvicorn) for Cloud Run
Alle tools kalles av Jason/Emma internt ingen 3part.
""" """
opax-mcp/server.py OPAX MCP Server
Eksponerer hele OPAX-plattformen som MCP-tools.
Kjører som egen Cloud Run-service: opax-mcp (us-central1).
Auth: Bearer token via MCP_SECRET env-var (Secret Manager: mcp-server-key).
Base: OPAX_BASE_URL (default https://opax.vauco.no)
Tools:
Billing: get_billing_summary, get_billing_forecast, get_billing_credits,
get_billing_anomalies, get_billing_history, get_billing_budget, set_billing_budget
Onboarding: create_invite, list_customers
Notify: send_webhook, send_email, send_sms
Agent: run_jason, run_emma
Platform: get_health, get_build_status, get_notify_channels,
get_state, get_telemetry
GitHub: list_commits, get_file, search_code
"""
import os import os
import sys
import json
import httpx import httpx
from typing import Any from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from typing import Optional, Any
from mcp.server.fastmcp import FastMCP app = FastAPI(title="opax-mcp", version="1.0.0")
# ── CONFIG ─────────────────────────────────────────────────────────────────
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no") OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
MCP_SECRET = os.environ.get("MCP_SECRET", "") MCP_SECRET = os.environ.get("MCP_SECRET", "")
GH_PAT = os.environ.get("GITHUB_PAT", "")
GH_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco") GH_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco")
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") GITHUB_PAT = os.environ.get("GITHUB_PAT", "")
GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
mcp = FastMCP("opax-mcp")
# ── HTTP HELPERS ───────────────────────────────────────────────────────────── def _auth_check(authorization: Optional[str]):
def _opax_headers() -> dict: if MCP_SECRET and authorization != f"Bearer {MCP_SECRET}":
headers = {"Content-Type": "application/json"} raise HTTPException(status_code=401, detail="Unauthorized")
if MCP_SECRET:
headers["Authorization"] = f"Bearer {MCP_SECRET}"
# IAP identity token (Cloud Run-to-Cloud Run)
try:
import google.auth
import google.auth.transport.requests
creds, _ = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
headers["x-goog-authenticated-user-email"] = "serviceAccount:opax-mcp"
# For IAP: send identity token
import urllib.request
meta_url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={OPAX_BASE_URL}&format=full"
req = urllib.request.Request(meta_url, headers={"Metadata-Flavor": "Google"})
token = urllib.request.urlopen(req, timeout=2).read().decode()
headers["Authorization"] = f"Bearer {token}"
except Exception:
pass
return headers
def _gh_headers() -> dict: # ---------------------------------------------------------------------------
h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} # Health
if GH_PAT: # ---------------------------------------------------------------------------
h["Authorization"] = f"Bearer {GH_PAT}"
return h @app.get("/health")
async def health():
return {"status": "ok", "service": "opax-mcp"}
async def _get(path: str, params: dict = None) -> Any: # ---------------------------------------------------------------------------
async with httpx.AsyncClient(timeout=20.0) as c: # Tool request/response schema
r = await c.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers(), params=params) # ---------------------------------------------------------------------------
class ToolRequest(BaseModel):
tool: str
params: dict = {}
@app.post("/tools/call")
async def call_tool(
req: ToolRequest,
authorization: Optional[str] = Header(default=None)
):
_auth_check(authorization)
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(authorization: Optional[str] = Header(default=None)):
_auth_check(authorization)
return {"tools": list(TOOLS.keys())}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _opax_get(path: str) -> Any:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{OPAX_BASE_URL}{path}")
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def _post(path: str, body: dict = None) -> Any: async def _opax_post(path: str, body: dict) -> Any:
async with httpx.AsyncClient(timeout=30.0) as c: async with httpx.AsyncClient(timeout=30) as client:
r = await c.post(f"{OPAX_BASE_URL}{path}", headers=_opax_headers(), json=body or {}) r = await client.post(f"{OPAX_BASE_URL}{path}", json=body)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def _gh_get(path: str, params: dict = None) -> Any: async def _gh_get(path: str) -> Any:
async with httpx.AsyncClient(timeout=15.0) as c: headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
r = await c.get(f"https://api.github.com{path}", headers=_gh_headers(), params=params) async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"https://api.github.com{path}", headers=headers)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
# ────────────────────────────────────────────────────────────────────────── async def _gh_post(path: str, body: dict) -> Any:
# BILLING TOOLS headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
# ────────────────────────────────────────────────────────────────────────── async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"https://api.github.com{path}", json=body, headers=headers)
@mcp.tool() r.raise_for_status()
async def get_billing_summary() -> str: return r.json()
"""Hent GCP-kostnadsoversikt per tjeneste for inneværende måned."""
data = await _get("/billing/summary")
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_forecast() -> str: # Billing tools
"""Hent kostnadsprøve og daglig snitt siste 7 dager.""" # ---------------------------------------------------------------------------
data = await _get("/billing/forecast")
return json.dumps(data, ensure_ascii=False, indent=2) async def get_billing_summary(p): return await _opax_get("/billing/summary")
async def get_billing_forecast(p): return await _opax_get("/billing/tokens/estimate")
async def get_billing_credits(p): return await _opax_get("/billing/summary")
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 set_billing_budget(p):
return await _opax_post("/billing/budget", {"amount": p.get("amount"), "currency": p.get("currency", "USD")})
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_credits(days: int = 90) -> str: # Onboarding tools
"""Hent Vertex/GCP-kredittbalanse, burn rate og estimert tom-dato.""" # ---------------------------------------------------------------------------
data = await _get("/billing/credits", params={"days": days})
return json.dumps(data, ensure_ascii=False, indent=2) async def create_invite(p):
return await _opax_post("/onboard/invite", {
"email": p.get("email"),
"name": p.get("name", ""),
"tier": p.get("tier", "starter")
})
async def list_customers(p):
return await _opax_get("/onboard/customers")
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_anomalies() -> str: # Notify tools
"""Detect kostnadsanomalier i GCP-forbruket.""" # ---------------------------------------------------------------------------
data = await _get("/billing/anomalies")
return json.dumps(data, ensure_ascii=False, indent=2) async def send_webhook(p):
return await _opax_post("/notify/webhook", {"message": p.get("message"), "url": p.get("url", "")})
async def send_email(p):
return await _opax_post("/notify/email", {"to": p.get("to"), "subject": p.get("subject"), "body": p.get("body")})
async def send_sms(p):
return await _opax_post("/notify/sms", {"to": p.get("to"), "message": p.get("message")})
async def get_notify_channels(p):
return await _opax_get("/notify/channels")
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_history() -> str: # Agent tools
"""Hent historisk daglig kostnad siste 90 dager.""" # ---------------------------------------------------------------------------
data = await _get("/billing/history")
return json.dumps(data, ensure_ascii=False, indent=2) async def run_jason(p):
return await _opax_post("/run", {"prompt": p.get("prompt"), "agent": "jason"})
async def run_emma(p):
return await _opax_post("/run", {"prompt": p.get("prompt"), "agent": "emma"})
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_budget() -> str: # Platform tools
"""Hent nåværende budsjettgrense (NOK).""" # ---------------------------------------------------------------------------
data = await _get("/billing/budget")
return json.dumps(data, ensure_ascii=False, indent=2) async def get_health(p): return await _opax_get("/health")
async def get_build_status(p): return await _opax_get("/build-status")
async def get_state(p): return await _opax_get("/state")
async def get_telemetry(p): return await _opax_get("/billing/tokens/by-module")
async def run_terminal(p):
return await _opax_post("/terminal/exec", {"command": p.get("command")})
@mcp.tool() # ---------------------------------------------------------------------------
async def set_billing_budget(budget: float) -> str: # GitHub tools
"""Sett ny budsjettgrense i NOK. Eksempel: 1500.0""" # ---------------------------------------------------------------------------
data = await _post("/billing/budget", {"budget": budget})
return json.dumps(data, ensure_ascii=False, indent=2)
async def list_commits(p):
repo = p.get("repo", GH_REPO)
return await _gh_get(f"/repos/{repo}/commits?per_page={p.get('limit', 10)}")
# ────────────────────────────────────────────────────────────────────────── async def get_file(p):
# ONBOARDING TOOLS repo = p.get("repo", GH_REPO)
# ────────────────────────────────────────────────────────────────────────── path = p.get("path", "")
return await _gh_get(f"/repos/{repo}/contents/{path}")
@mcp.tool() async def list_open_issues(p):
async def create_invite(company: str, email: str, tier: str = "starter") -> str: repo = p.get("repo", GH_REPO)
""" return await _gh_get(f"/repos/{repo}/issues?state=open&per_page=20")
Opprett en CostGuard-invitasjon og send til prospekt.
Returnerer ferdig onboarding-URL (72t gyldig).
tier: starter | guard | shield | enterprise
"""
data = await _post("/onboard/invite", {"company": company, "email": email, "tier": tier})
return json.dumps(data, ensure_ascii=False, indent=2)
async def create_github_issue(p):
repo = p.get("repo", GH_REPO)
return await _gh_post(f"/repos/{repo}/issues", {
"title": p.get("title"),
"body": p.get("body", "")
})
@mcp.tool() async def push_file(p):
async def list_customers() -> str: """Oppdater en enkelt fil i GitHub via contents API."""
"""List alle onboardede CostGuard-kunder fra Firestore.""" repo = p.get("repo", GH_REPO)
try: path = p.get("path")
import google.auth
from google.cloud import firestore
google.auth.default()
db = firestore.Client(project=PROJECT_ID)
docs = db.collection("onboarded_customers").stream()
customers = []
for doc in docs:
d = doc.to_dict()
d["id"] = doc.id
# Fjern sensitive felter
d.pop("webhook_url", None)
customers.append(d)
return json.dumps({"customers": customers, "count": len(customers)}, ensure_ascii=False, indent=2, default=str)
except Exception as e:
return json.dumps({"error": str(e)})
# ──────────────────────────────────────────────────────────────────────────
# NOTIFY TOOLS
# ──────────────────────────────────────────────────────────────────────────
@mcp.tool()
async def send_webhook(url: str, title: str, body: str, event: str = "custom") -> str:
"""Send varsel til Slack/Teams/Google Chat webhook."""
data = await _post("/notify/webhook", {"url": url, "title": title, "body": body, "event": event})
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def send_email(to: str, subject: str, body_html: str, event: str = "custom") -> str:
"""Send e-post via SendGrid. body_html kan være enkel HTML eller ren tekst."""
data = await _post("/notify/email", {"to": to, "subject": subject, "body_html": body_html, "event": event})
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def send_sms(to: str, body: str, tier: str, event: str = "spike") -> str:
"""
Send SMS via Twilio. Krever Guard+-tier.
to: mobilnummer i E.164-format (+4712345678)
"""
data = await _post("/notify/sms", {"to": to, "body": body, "event": event, "tier": tier})
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def get_notify_channels() -> str:
"""List alle konfigurerte varslingskanaler (email, sms, webhook)."""
data = await _get("/notify/channels")
return json.dumps(data, ensure_ascii=False, indent=2)
# ──────────────────────────────────────────────────────────────────────────
# AGENT TOOLS
# ──────────────────────────────────────────────────────────────────────────
@mcp.tool()
async def run_jason(message: str, session_id: str = "mcp", mode: str = "light") -> str:
"""
Kjør Jason (kundevendt agent, gemini-2.5-flash).
mode: light | heavy
"""
data = await _post("/run", {"message": message, "user_id": "mcp", "session_id": session_id, "mode": mode})
return data.get("response", json.dumps(data))
@mcp.tool()
async def run_emma(message: str, session_id: str = "mcp-emma", history: list = None) -> str:
"""
Kjør Emma (intern agent, Vertex/Gemma).
history: [{"role": "user"|"model", "content": str}]
"""
data = await _post("/emma", {"message": message, "session_id": session_id, "history": history or []})
return data.get("response", json.dumps(data))
# ──────────────────────────────────────────────────────────────────────────
# PLATFORM TOOLS
# ──────────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_health() -> str:
"""Sjekk om OPAX Cloud Run-service er oppe."""
data = await _get("/health")
return json.dumps(data, ensure_ascii=False)
@mcp.tool()
async def get_build_status() -> str:
"""Hent siste 5 Cloud Build-kjøringer og status."""
data = await _get("/opax/build-status")
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def get_state() -> str:
"""Hent OPAX agent-state snapshot (telemetri, siste kjøringer)."""
data = await _get("/state")
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def get_telemetry(limit: int = 20) -> str:
"""Hent OPAX agent-telemetri historikk."""
data = await _get("/telemetry/history", params={"limit": limit})
return json.dumps(data, ensure_ascii=False, indent=2)
@mcp.tool()
async def run_terminal(cmd: str) -> str:
"""
Kjør whitelisted OPAX terminal-kommando.
Gyldige: health | billing | build | logs | help
"""
data = await _post("/terminal/exec", {"cmd": cmd})
return data.get("output", json.dumps(data))
# ──────────────────────────────────────────────────────────────────────────
# GITHUB TOOLS
# ──────────────────────────────────────────────────────────────────────────
@mcp.tool()
async def list_commits(branch: str = "main", per_page: int = 10) -> str:
"""List siste commits i OSVauco-repo."""
data = await _gh_get(f"/repos/{GH_REPO}/commits", params={"sha": branch, "per_page": per_page})
commits = [{"sha": c["sha"][:8], "message": c["commit"]["message"].split("\n")[0], "author": c["commit"]["author"]["name"], "date": c["commit"]["author"]["date"]} for c in data]
return json.dumps(commits, ensure_ascii=False, indent=2)
@mcp.tool()
async def get_file(path: str, ref: str = "main") -> str:
"""Hent innholdet i en fil fra OSVauco-repo. path: f.eks. 'docs/MASTERPLAN.md'"""
data = await _gh_get(f"/repos/{GH_REPO}/contents/{path}", params={"ref": ref})
import base64 import base64
content = base64.b64decode(data["content"]).decode("utf-8") content_b64 = base64.b64encode(p.get("content", "").encode()).decode()
return content body = {
"message": p.get("message", f"chore: oppdater {path} via opax-mcp"),
"content": content_b64,
}
if p.get("sha"):
body["sha"] = p["sha"]
return await _gh_post(f"/repos/{repo}/contents/{path}", body)
@mcp.tool() # ---------------------------------------------------------------------------
async def list_open_issues() -> str: # Tool registry
"""List åpne issues i OSVauco-repo (OQ-lista).""" # ---------------------------------------------------------------------------
data = await _gh_get(f"/repos/{GH_REPO}/issues", params={"state": "open", "per_page": 30})
issues = [{"number": i["number"], "title": i["title"], "labels": [l["name"] for l in i["labels"]], "created_at": i["created_at"]} for i in data if "pull_request" not in i]
return json.dumps(issues, ensure_ascii=False, indent=2)
TOOLS = {
@mcp.tool() # Billing
async def create_github_issue(title: str, body: str, labels: list = None) -> str: "get_billing_summary": get_billing_summary,
"""Opprett et nytt GitHub issue i OSVauco-repo.""" "get_billing_forecast": get_billing_forecast,
async with httpx.AsyncClient(timeout=15.0) as c: "get_billing_credits": get_billing_credits,
r = await c.post( "get_billing_anomalies": get_billing_anomalies,
f"https://api.github.com/repos/{GH_REPO}/issues", "get_billing_history": get_billing_history,
headers=_gh_headers(), "get_billing_budget": get_billing_budget,
json={"title": title, "body": body, "labels": labels or []} "set_billing_budget": set_billing_budget,
) # Onboarding
r.raise_for_status() "create_invite": create_invite,
data = r.json() "list_customers": list_customers,
return json.dumps({"number": data["number"], "url": data["html_url"], "title": data["title"]}, indent=2) # Notify
"send_webhook": send_webhook,
"send_email": send_email,
@mcp.tool() "send_sms": send_sms,
async def push_file(path: str, content: str, message: str, branch: str = "main") -> str: "get_notify_channels": get_notify_channels,
""" # Agents
Opprett eller oppdater en fil i OSVauco-repo. "run_jason": run_jason,
Henter nåværende SHA automatisk hvis filen eksisterer. "run_emma": run_emma,
""" # Platform
# Hent nåværende SHA "get_health": get_health,
sha = None "get_build_status": get_build_status,
try: "get_state": get_state,
existing = await _gh_get(f"/repos/{GH_REPO}/contents/{path}", params={"ref": branch}) "get_telemetry": get_telemetry,
sha = existing.get("sha") "run_terminal": run_terminal,
except Exception: # GitHub
pass "list_commits": list_commits,
"get_file": get_file,
import base64 "list_open_issues": list_open_issues,
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") "create_github_issue": create_github_issue,
payload = {"message": message, "content": encoded, "branch": branch} "push_file": push_file,
if sha: }
payload["sha"] = sha
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.put(
f"https://api.github.com/repos/{GH_REPO}/contents/{path}",
headers=_gh_headers(),
json=payload
)
r.raise_for_status()
data = r.json()
return json.dumps({"path": path, "sha": data["content"]["sha"][:8], "url": data["content"]["html_url"]}, indent=2)
# ── ENTRYPOINT ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")