# agents/mcp_server/server.py # OPAX-MCP — Vauco sin eigen MCP-server / CI-kanal mot GCP import os import logging from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Depends, Header from pydantic import BaseModel logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") REGION = os.environ.get("REGION", "us-central1") STATIC_BUCKET = os.environ.get("STATIC_BUCKET", "opax-vauco-static") CLOUD_RUN_SERVICE = os.environ.get("CLOUD_RUN_SERVICE", "osvauco-agent") MCP_SECRET = os.environ.get("MCP_SECRET", "") BUILD_TRIGGER_ID = os.environ.get("CLOUD_BUILD_TRIGGER_ID", "38423976-91ff-4ff4-859e-1f262344c609") def verify_token(x_mcp_key: str = Header(default="")): if MCP_SECRET and x_mcp_key != MCP_SECRET: raise HTTPException(status_code=401, detail="Ugyldig MCP-nøkkel") return True def _get_access_token() -> str: import google.auth import google.auth.transport.requests creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) creds.refresh(google.auth.transport.requests.Request()) return creds.token def _list_builds_rest(project_id: str, region: str, trigger_id: str, page_size: int = 1): """Kaller Cloud Build v1 REST direkte mot regional endpoint.""" import urllib.request import json token = _get_access_token() url = ( f"https://cloudbuild.googleapis.com/v1/" f"projects/{project_id}/locations/{region}/builds" f"?filter=trigger_id%3D%22{trigger_id}%22&pageSize={page_size}" ) req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read()) class PushStaticRequest(BaseModel): file_path: str content: str content_type: str = "text/html; charset=utf-8" cache_control: str = "public, max-age=300" class DeployServiceRequest(BaseModel): branch: str = "main" trigger_id: str = "" substitutions: dict = {} class ToolCallRequest(BaseModel): tool: str params: dict = {} @asynccontextmanager async def lifespan(app: FastAPI): logger.info(f"OPAX-MCP starting | project={PROJECT_ID} | region={REGION} | trigger={BUILD_TRIGGER_ID}") yield app = FastAPI(title="OPAX-MCP", version="1.0.0", lifespan=lifespan) @app.get("/health") async def health(): return {"status": "ok", "service": "opax-mcp", "project": PROJECT_ID, "region": REGION} @app.get("/mcp/tools") async def list_tools(_: bool = Depends(verify_token)): return {"tools": [ {"name": "push_static", "description": "Last opp statisk fil til GCS (opax.vauco.no)"}, {"name": "get_build_status", "description": "Hent status på siste Cloud Build for osvauco-agent-main-trigger"}, {"name": "deploy_service", "description": "Trigger ny Cloud Build deploy (HITL)"}, {"name": "get_logs", "description": "Hent Cloud Run-logger for osvauco-agent"}, ]} @app.post("/tools/push_static") async def push_static(req: PushStaticRequest, _: bool = Depends(verify_token)): try: from google.cloud import storage client = storage.Client(project=PROJECT_ID) blob = client.bucket(STATIC_BUCKET).blob(req.file_path) blob.cache_control = req.cache_control blob.upload_from_string(req.content.encode("utf-8"), content_type=req.content_type) logger.info(f"[push_static] {req.file_path} ({len(req.content)} bytes)") return {"ok": True, "file_path": req.file_path, "bucket": STATIC_BUCKET, "bytes": len(req.content.encode("utf-8")), "url": f"https://storage.googleapis.com/{STATIC_BUCKET}/{req.file_path}"} except Exception as e: logger.error(f"[push_static] {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.get("/tools/get_build_status") async def get_build_status(_: bool = Depends(verify_token)): """ Henter siste build fra osvauco-agent-main-trigger via REST mot cloudbuild.googleapis.com/v1/projects/{p}/locations/{r}/builds """ try: data = _list_builds_rest(PROJECT_ID, REGION, BUILD_TRIGGER_ID, page_size=1) builds = data.get("builds", []) if not builds: return {"status": "unknown", "message": f"Ingen builds for trigger {BUILD_TRIGGER_ID} i {REGION}"} b = builds[0] status_str = b.get("status", "unknown").lower() subs = b.get("substitutions", {}) start = b.get("startTime") finish = b.get("finishTime") duration_s = None if start and finish: from datetime import datetime, timezone fmt = "%Y-%m-%dT%H:%M:%S.%fZ" try: t0 = datetime.strptime(start, fmt).replace(tzinfo=timezone.utc) t1 = datetime.strptime(finish, fmt).replace(tzinfo=timezone.utc) duration_s = int((t1 - t0).total_seconds()) except Exception: pass return { "status": status_str, "build_id": b.get("id", ""), "branch": subs.get("BRANCH_NAME", "main"), "commit": subs.get("SHORT_SHA", ""), "duration_s": duration_s, "log_url": b.get("logUrl", ""), "region": REGION, "trigger_id": BUILD_TRIGGER_ID, } except Exception as e: logger.error(f"[get_build_status] {e}", exc_info=True) return {"status": "error", "message": str(e)} @app.post("/tools/deploy_service") async def deploy_service(req: DeployServiceRequest, _: bool = Depends(verify_token)): try: from google.cloud.devtools import cloudbuild_v1 from google.api_core.client_options import ClientOptions client = cloudbuild_v1.CloudBuildClient( client_options=ClientOptions(api_endpoint=f"{REGION}-cloudbuild.googleapis.com") ) trigger_id = req.trigger_id or BUILD_TRIGGER_ID subs = {"BRANCH_NAME": req.branch, **req.substitutions} response = client.run_build_trigger( project_id=PROJECT_ID, trigger_id=trigger_id, source=cloudbuild_v1.RepoSource(branch_name=req.branch, substitutions=subs), ) build_id = response.metadata.build.id if hasattr(response, 'metadata') else "ukjent" logger.info(f"[deploy_service] build {build_id} branch {req.branch}") return {"ok": True, "build_id": build_id, "branch": req.branch, "trigger_id": trigger_id} except HTTPException: raise except Exception as e: logger.error(f"[deploy_service] {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.get("/tools/get_logs") async def get_logs(lines: int = 50, severity: str = "DEFAULT", _: bool = Depends(verify_token)): try: from google.cloud import logging as gcloud_logging client = gcloud_logging.Client(project=PROJECT_ID) filter_str = (f'resource.type="cloud_run_revision" ' f'resource.labels.service_name="{CLOUD_RUN_SERVICE}"') if severity and severity != "DEFAULT": filter_str += f' severity>={severity}' entries = list(client.list_entries( filter_=filter_str, order_by=gcloud_logging.DESCENDING, page_size=lines)) log_lines = [] for entry in entries: payload = entry.payload text = payload.get("message", str(payload)) if isinstance(payload, dict) else str(payload) log_lines.append({"timestamp": str(entry.timestamp), "severity": str(entry.severity), "text": text[:400]}) return {"service": CLOUD_RUN_SERVICE, "count": len(log_lines), "logs": log_lines} except Exception as e: logger.error(f"[get_logs] {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @app.post("/mcp/call") async def mcp_call(req: ToolCallRequest, _: bool = Depends(verify_token)): if req.tool == "push_static": return await push_static(PushStaticRequest(**req.params), True) elif req.tool == "get_build_status": return await get_build_status(True) elif req.tool == "deploy_service": return await deploy_service(DeployServiceRequest(**req.params), True) elif req.tool == "get_logs": return await get_logs(lines=req.params.get("lines", 50), severity=req.params.get("severity", "DEFAULT"), _=True) else: raise HTTPException(status_code=404, detail=f"Ukjent tool: {req.tool}")