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 . .
# MCP over HTTP (SSE transport) for Cloud Run
ENV MCP_TRANSPORT=sse
ENV PORT=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
google-auth>=2.29.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 sys
import json
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")
MCP_SECRET = os.environ.get("MCP_SECRET", "")
GH_PAT = os.environ.get("GITHUB_PAT", "")
GH_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco")
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
mcp = FastMCP("opax-mcp")
GITHUB_PAT = os.environ.get("GITHUB_PAT", "")
GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
# ── HTTP HELPERS ─────────────────────────────────────────────────────────────
def _opax_headers() -> dict:
headers = {"Content-Type": "application/json"}
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 _auth_check(authorization: Optional[str]):
if MCP_SECRET and authorization != f"Bearer {MCP_SECRET}":
raise HTTPException(status_code=401, detail="Unauthorized")
def _gh_headers() -> dict:
h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
if GH_PAT:
h["Authorization"] = f"Bearer {GH_PAT}"
return h
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@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:
r = await c.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers(), params=params)
# ---------------------------------------------------------------------------
# Tool request/response schema
# ---------------------------------------------------------------------------
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()
return r.json()
async def _post(path: str, body: dict = None) -> Any:
async with httpx.AsyncClient(timeout=30.0) as c:
r = await c.post(f"{OPAX_BASE_URL}{path}", headers=_opax_headers(), json=body or {})
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)
r.raise_for_status()
return r.json()
async def _gh_get(path: str, params: dict = None) -> Any:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"https://api.github.com{path}", headers=_gh_headers(), params=params)
async def _gh_get(path: str) -> Any:
headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"https://api.github.com{path}", headers=headers)
r.raise_for_status()
return r.json()
# ──────────────────────────────────────────────────────────────────────────
# BILLING TOOLS
# ──────────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_billing_summary() -> str:
"""Hent GCP-kostnadsoversikt per tjeneste for inneværende måned."""
data = await _get("/billing/summary")
return json.dumps(data, ensure_ascii=False, indent=2)
async def _gh_post(path: str, body: dict) -> Any:
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)
r.raise_for_status()
return r.json()
@mcp.tool()
async def get_billing_forecast() -> str:
"""Hent kostnadsprøve og daglig snitt siste 7 dager."""
data = await _get("/billing/forecast")
return json.dumps(data, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# 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/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:
"""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)
# ---------------------------------------------------------------------------
# Onboarding tools
# ---------------------------------------------------------------------------
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:
"""Detect kostnadsanomalier i GCP-forbruket."""
data = await _get("/billing/anomalies")
return json.dumps(data, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Notify tools
# ---------------------------------------------------------------------------
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:
"""Hent historisk daglig kostnad siste 90 dager."""
data = await _get("/billing/history")
return json.dumps(data, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Agent tools
# ---------------------------------------------------------------------------
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:
"""Hent nåværende budsjettgrense (NOK)."""
data = await _get("/billing/budget")
return json.dumps(data, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Platform tools
# ---------------------------------------------------------------------------
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:
"""Sett ny budsjettgrense i NOK. Eksempel: 1500.0"""
data = await _post("/billing/budget", {"budget": budget})
return json.dumps(data, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# GitHub tools
# ---------------------------------------------------------------------------
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)}")
# ──────────────────────────────────────────────────────────────────────────
# ONBOARDING TOOLS
# ──────────────────────────────────────────────────────────────────────────
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 create_invite(company: str, email: str, tier: str = "starter") -> str:
"""
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 list_open_issues(p):
repo = p.get("repo", GH_REPO)
return await _gh_get(f"/repos/{repo}/issues?state=open&per_page=20")
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 list_customers() -> str:
"""List alle onboardede CostGuard-kunder fra Firestore."""
try:
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})
async def push_file(p):
"""Oppdater en enkelt fil i GitHub via contents API."""
repo = p.get("repo", GH_REPO)
path = p.get("path")
import base64
content = base64.b64decode(data["content"]).decode("utf-8")
return content
content_b64 = base64.b64encode(p.get("content", "").encode()).decode()
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:
"""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)
# ---------------------------------------------------------------------------
# Tool registry
# ---------------------------------------------------------------------------
@mcp.tool()
async def create_github_issue(title: str, body: str, labels: list = None) -> str:
"""Opprett et nytt GitHub issue i OSVauco-repo."""
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.post(
f"https://api.github.com/repos/{GH_REPO}/issues",
headers=_gh_headers(),
json={"title": title, "body": body, "labels": labels or []}
)
r.raise_for_status()
data = r.json()
return json.dumps({"number": data["number"], "url": data["html_url"], "title": data["title"]}, indent=2)
@mcp.tool()
async def push_file(path: str, content: str, message: str, branch: str = "main") -> str:
"""
Opprett eller oppdater en fil i OSVauco-repo.
Henter nåværende SHA automatisk hvis filen eksisterer.
"""
# Hent nåværende SHA
sha = None
try:
existing = await _gh_get(f"/repos/{GH_REPO}/contents/{path}", params={"ref": branch})
sha = existing.get("sha")
except Exception:
pass
import base64
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
payload = {"message": message, "content": encoded, "branch": branch}
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")
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,
# 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,
# GitHub
"list_commits": list_commits,
"get_file": get_file,
"list_open_issues": list_open_issues,
"create_github_issue": create_github_issue,
"push_file": push_file,
}