263 lines
10 KiB
Python
263 lines
10 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, Request
|
|
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")
|
|
|
|
_OPAX_MCP_SERVICE = "opax-mcp"
|
|
_OPAX_MCP_REGION = "us-central1"
|
|
|
|
|
|
def verify_token(request: Request, x_mcp_key: str = Header(default="")):
|
|
# Aksepter både X-MCP-Key og api-key (Perplexity MCP connector bruker api-key)
|
|
token = x_mcp_key or request.headers.get("api-key", "")
|
|
if MCP_SECRET and token != 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):
|
|
"""Parser tidsstempel robust — aksepterer str, DatetimeWithNanoseconds eller datetime."""
|
|
from datetime import datetime, timezone
|
|
if ts is None:
|
|
return None
|
|
if hasattr(ts, 'year'):
|
|
if getattr(ts, 'tzinfo', None) is None:
|
|
return ts.replace(tzinfo=timezone.utc)
|
|
return ts
|
|
ts = str(ts).rstrip("Z")
|
|
try:
|
|
clean_ts = ts[:19]
|
|
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:
|
|
return None
|
|
|
|
|
|
def _sanitize_opax_deployment_status(service_data: dict) -> dict:
|
|
"""
|
|
Takes a decoded Cloud Run v2 service JSON object and returns a sanitized
|
|
dict conforming to the minimal v1 output contract.
|
|
"""
|
|
def _validate_generation(val):
|
|
if isinstance(val, bool):
|
|
return None
|
|
if isinstance(val, int) and val >= 0:
|
|
return val
|
|
if isinstance(val, str) and val.isascii() and val.isdecimal():
|
|
return int(val)
|
|
return None
|
|
|
|
def _validate_update_time(val):
|
|
from datetime import datetime, timezone
|
|
if (
|
|
not isinstance(val, str)
|
|
or not val.endswith("Z")
|
|
or val.endswith("ZZ")
|
|
or len(val) < 20
|
|
or val[10] != "T"
|
|
):
|
|
return None
|
|
try:
|
|
normalized_val = val[:-1] + "+00:00"
|
|
dt = datetime.fromisoformat(normalized_val)
|
|
if dt.tzinfo is None:
|
|
return None # Reject timezone-naive
|
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except ValueError:
|
|
return None
|
|
|
|
return {
|
|
"service": _OPAX_MCP_SERVICE,
|
|
"region": _OPAX_MCP_REGION,
|
|
"generation": _validate_generation(service_data.get("generation")),
|
|
"observed_generation": _validate_generation(service_data.get("observedGeneration")),
|
|
"last_update_time": _validate_update_time(service_data.get("updateTime")),
|
|
"reason_code": 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)
|
|
ts = _parse_ts(entry.timestamp)
|
|
log_lines.append({"timestamp": ts.isoformat() if ts else 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}")
|