From 27290b2ab6fa00147f4ca65d8dc288325eaafef1 Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Mon, 15 Jun 2026 18:11:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(CI1):=20opax-mcp=20=E2=80=94=20bytt=20til?= =?UTF-8?q?=20uvicorn/FastAPI=20HTTP=20server,=20legg=20til=20/health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- opax-mcp/Dockerfile | 4 +- opax-mcp/requirements.txt | 3 +- opax-mcp/server.py | 507 +++++++++++++++----------------------- 3 files changed, 199 insertions(+), 315 deletions(-) diff --git a/opax-mcp/Dockerfile b/opax-mcp/Dockerfile index c7e5144..ce4acb2 100644 --- a/opax-mcp/Dockerfile +++ b/opax-mcp/Dockerfile @@ -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"] diff --git a/opax-mcp/requirements.txt b/opax-mcp/requirements.txt index abf11e5..3cf41b3 100644 --- a/opax-mcp/requirements.txt +++ b/opax-mcp/requirements.txt @@ -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 diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 2ece6b4..c1bbc52 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -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") +OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no") +MCP_SECRET = os.environ.get("MCP_SECRET", "") +GH_REPO = os.environ.get("GH_REPO", "vauco-saas/OSVauco") +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) - r.raise_for_status() - return r.json() +# --------------------------------------------------------------------------- +# Tool request/response schema +# --------------------------------------------------------------------------- + +class ToolRequest(BaseModel): + tool: str + params: dict = {} -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 {}) - r.raise_for_status() - return r.json() +@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} -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) - r.raise_for_status() - return r.json() +@app.get("/tools") +async def list_tools(authorization: Optional[str] = Header(default=None)): + _auth_check(authorization) + return {"tools": list(TOOLS.keys())} -# ────────────────────────────────────────────────────────────────────────── -# BILLING TOOLS -# ────────────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -@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 _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() -@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) +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() -@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) +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() -@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) +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_history() -> str: - """Hent historisk daglig kostnad siste 90 dager.""" - data = await _get("/billing/history") - 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_budget() -> str: - """Hent nåværende budsjettgrense (NOK).""" - data = await _get("/billing/budget") - 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 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) +# --------------------------------------------------------------------------- +# 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") -# ────────────────────────────────────────────────────────────────────────── -# ONBOARDING TOOLS -# ────────────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +# Agent tools +# --------------------------------------------------------------------------- -@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 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 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)}) +# --------------------------------------------------------------------------- +# 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")}) -# ────────────────────────────────────────────────────────────────────────── -# NOTIFY TOOLS -# ────────────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +# GitHub 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) +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): + repo = p.get("repo", GH_REPO) + path = p.get("path", "") + return await _gh_get(f"/repos/{repo}/contents/{path}") -@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) +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 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, +}