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_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco")
GH_PAT = os.environ.get("GITHUB_PAT", "") GITHUB_PAT = os.environ.get("GITHUB_PAT", "")
GH_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco") GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
PROJECT_ID = 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) # ---------------------------------------------------------------------------
r.raise_for_status()
return r.json() class ToolRequest(BaseModel):
tool: str
params: dict = {}
async def _post(path: str, body: dict = None) -> Any: @app.post("/tools/call")
async with httpx.AsyncClient(timeout=30.0) as c: async def call_tool(
r = await c.post(f"{OPAX_BASE_URL}{path}", headers=_opax_headers(), json=body or {}) req: ToolRequest,
r.raise_for_status() authorization: Optional[str] = Header(default=None)
return r.json() ):
_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}
async def _gh_get(path: str, params: dict = None) -> Any: @app.get("/tools")
async with httpx.AsyncClient(timeout=15.0) as c: async def list_tools(authorization: Optional[str] = Header(default=None)):
r = await c.get(f"https://api.github.com{path}", headers=_gh_headers(), params=params) _auth_check(authorization)
r.raise_for_status() return {"tools": list(TOOLS.keys())}
return r.json()
# ────────────────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# BILLING TOOLS # Helpers
# ────────────────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@mcp.tool() async def _opax_get(path: str) -> Any:
async def get_billing_summary() -> str: async with httpx.AsyncClient(timeout=30) as client:
"""Hent GCP-kostnadsoversikt per tjeneste for inneværende måned.""" r = await client.get(f"{OPAX_BASE_URL}{path}")
data = await _get("/billing/summary") r.raise_for_status()
return json.dumps(data, ensure_ascii=False, indent=2) return r.json()
@mcp.tool() async def _opax_post(path: str, body: dict) -> Any:
async def get_billing_forecast() -> str: async with httpx.AsyncClient(timeout=30) as client:
"""Hent kostnadsprøve og daglig snitt siste 7 dager.""" r = await client.post(f"{OPAX_BASE_URL}{path}", json=body)
data = await _get("/billing/forecast") r.raise_for_status()
return json.dumps(data, ensure_ascii=False, indent=2) return r.json()
@mcp.tool() async def _gh_get(path: str) -> Any:
async def get_billing_credits(days: int = 90) -> str: headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
"""Hent Vertex/GCP-kredittbalanse, burn rate og estimert tom-dato.""" async with httpx.AsyncClient(timeout=30) as client:
data = await _get("/billing/credits", params={"days": days}) r = await client.get(f"https://api.github.com{path}", headers=headers)
return json.dumps(data, ensure_ascii=False, indent=2) r.raise_for_status()
return r.json()
@mcp.tool() async def _gh_post(path: str, body: dict) -> Any:
async def get_billing_anomalies() -> str: headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
"""Detect kostnadsanomalier i GCP-forbruket.""" async with httpx.AsyncClient(timeout=30) as client:
data = await _get("/billing/anomalies") r = await client.post(f"https://api.github.com{path}", json=body, headers=headers)
return json.dumps(data, ensure_ascii=False, indent=2) r.raise_for_status()
return r.json()
@mcp.tool() # ---------------------------------------------------------------------------
async def get_billing_history() -> str: # Billing tools
"""Hent historisk daglig kostnad siste 90 dager.""" # ---------------------------------------------------------------------------
data = await _get("/billing/history")
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_budget() -> str: # Onboarding tools
"""Hent nåværende budsjettgrense (NOK).""" # ---------------------------------------------------------------------------
data = await _get("/billing/budget")
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 set_billing_budget(budget: float) -> str: # Notify 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 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")
# ────────────────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
# ONBOARDING TOOLS # Agent tools
# ────────────────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@mcp.tool() async def run_jason(p):
async def create_invite(company: str, email: str, tier: str = "starter") -> str: return await _opax_post("/run", {"prompt": p.get("prompt"), "agent": "jason"})
"""
Opprett en CostGuard-invitasjon og send til prospekt. async def run_emma(p):
Returnerer ferdig onboarding-URL (72t gyldig). return await _opax_post("/run", {"prompt": p.get("prompt"), "agent": "emma"})
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)
@mcp.tool() # ---------------------------------------------------------------------------
async def list_customers() -> str: # Platform tools
"""List alle onboardede CostGuard-kunder fra Firestore.""" # ---------------------------------------------------------------------------
try:
import google.auth async def get_health(p): return await _opax_get("/health")
from google.cloud import firestore async def get_build_status(p): return await _opax_get("/build-status")
google.auth.default() async def get_state(p): return await _opax_get("/state")
db = firestore.Client(project=PROJECT_ID) async def get_telemetry(p): return await _opax_get("/billing/tokens/by-module")
docs = db.collection("onboarded_customers").stream()
customers = [] async def run_terminal(p):
for doc in docs: return await _opax_post("/terminal/exec", {"command": p.get("command")})
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 # GitHub tools
# ────────────────────────────────────────────────────────────────────────── # ---------------------------------------------------------------------------
@mcp.tool() async def list_commits(p):
async def send_webhook(url: str, title: str, body: str, event: str = "custom") -> str: repo = p.get("repo", GH_REPO)
"""Send varsel til Slack/Teams/Google Chat webhook.""" return await _gh_get(f"/repos/{repo}/commits?per_page={p.get('limit', 10)}")
data = await _post("/notify/webhook", {"url": url, "title": title, "body": body, "event": event})
return json.dumps(data, ensure_ascii=False, indent=2)
async def get_file(p):
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 send_email(to: str, subject: str, body_html: str, event: str = "custom") -> str: repo = p.get("repo", GH_REPO)
"""Send e-post via SendGrid. body_html kan være enkel HTML eller ren tekst.""" return await _gh_get(f"/repos/{repo}/issues?state=open&per_page=20")
data = await _post("/notify/email", {"to": to, "subject": subject, "body_html": body_html, "event": event})
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 send_sms(to: str, body: str, tier: str, event: str = "spike") -> str: """Oppdater en enkelt fil i GitHub via contents API."""
""" repo = p.get("repo", GH_REPO)
Send SMS via Twilio. Krever Guard+-tier. path = p.get("path")
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")