OSVauco/agents/mcp_server/server.py

219 lines
8.9 KiB
Python

# 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):
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())
def _parse_ts(ts: str):
"""Parser RFC3339-tidsstempel robust, returnerer datetime eller None."""
from datetime import datetime, timezone
if not ts:
return None
ts = ts.rstrip("Z")
# Fjern eventuell timezone-offset (+00:00) og mikrosekunder
for fmt in (
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
):
try:
# Klipp bort offset-del før parsing
clean = ts.split("+")[0].split("-")[:3]
# Rekonstruer uten offset
clean_ts = ts[:19] # YYYY-MM-DDTHH:MM:SS
if "." in ts:
frac = ts[20:26].split("+")[0].split("-")[0] if len(ts) > 20 else "000000"
clean_ts = ts[:19] + "." + frac.ljust(6, "0")[:6]
return datetime.strptime(clean_ts, "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=timezone.utc)
return datetime.strptime(clean_ts, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
except Exception:
continue
return None
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)):
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", {})
t0 = _parse_ts(b.get("startTime"))
t1 = _parse_ts(b.get("finishTime"))
duration_s = int((t1 - t0).total_seconds()) if t0 and t1 else None
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}")