OSVauco/opax-mcp/server.py

893 lines
44 KiB
Python

"""opax-mcp — MCP Streamable HTTP server for OPAX/Vauco
Transport: MCP Streamable HTTP (JSON-RPC 2.0) on POST /
Auth: Authorization: Bearer <secret> OR X-MCP-Secret: <secret> OR api-key: <secret>
"""
import os
import json
import uuid
import httpx
import base64
import google.auth
import google.auth.transport.requests
import google.oauth2.id_token
from googleapiclient.discovery import build
from google.cloud import secretmanager
from google.oauth2 import service_account
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from email.mime.text import MIMEText
from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta
from typing import Any, Optional
import logging
from pydantic import BaseModel
import subprocess
import shlex
import re
from pathlib import Path
# This allowlist defines the strict, minimum-privilege toolset exposed
# to the web-agent integration route.
WEB_AGENT_ALLOWED_TOOLS = frozenset({
"get_health",
"get_build_status",
"get_state",
"get_telemetry",
"get_git_status",
"get_git_branch",
"get_git_log",
"get_git_diff",
"list_repo_files",
"read_repo_file",
})
# --- Sikkerhets- og konfigurasjonskonstanter for Git-verktøy ---
REPO_ROOT = Path("/app/OSVauco").resolve()
ALLOWED_REMOTE_HOSTS = {"git.vauco.no"}
ALLOWED_REMOTE_PATH = "chris/OSVauco"
GIT_TIMEOUT_READ = 30
GIT_TIMEOUT_WRITE = 60
GENERIC_SECRET_PATTERNS = [
re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"),
re.compile(r"glpat-[A-Za-z0-9_-]{20,}"),
re.compile(r"gitea_[A-Za-z0-9_-]{10,}"),
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
]
SECRET_MASK_PATTERNS = []
for var in ("MCP_SECRET", "GITEA_TOKEN", "INTERNAL_API_KEY", "GITHUB_TOKEN"):
val = os.environ.get(var)
if val:
SECRET_MASK_PATTERNS.append(re.compile(re.escape(val)))
class ConfirmationRequired(BaseModel):
message: str
preview: dict
requires_confirmation: bool = True
def require_confirmation(confirm: bool, preview: dict, message: str = "This action requires confirmation"):
if not confirm:
raise HTTPException(
status_code=400,
detail=ConfirmationRequired(message=message, preview=preview).model_dump()
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="opax-mcp", version="3.5.0") # BUMPED
# ── Service discovery ───────────────────────────────────────────────────────
OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app
MCP_SECRET = os.environ.get("MCP_SECRET", "")
INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "")
GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco")
GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
WORKSPACE_ADMIN_USER = "chris.christiansen@vauco.no"
# Emma-vm Ollama — direkte tilkobling
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://34.13.238.133:11434")
EMMA_MODEL = os.environ.get("EMMA_MODEL", "gemma3:27b")
EMMA_FAST_MODEL = os.environ.get("EMMA_FAST_MODEL", "gemma3:4b")
EMMA_LIGHT_MODEL = os.environ.get("EMMA_LIGHT_MODEL", "qwen2.5:3b")
logger.info(f"OSVAUCO_AGENT_URL: {OSVAUCO_AGENT_URL}")
logger.info(f"OLLAMA_BASE_URL: {OLLAMA_BASE_URL}")
# ---------------------------------------------------------------------------
# Auth (kun for innkommende kall til opax-mcp, f.eks. fra Gemini TUI)
# ---------------------------------------------------------------------------
async def _verify_auth(request: Request) -> None:
logger.info(
"MCP auth attempt: has_api_key=%s has_x_mcp_secret=%s has_bearer=%s",
bool(request.headers.get("api-key")),
bool(request.headers.get("X-MCP-Secret")),
request.headers.get("Authorization", "").startswith("Bearer "),
)
secret = os.getenv("MCPSECRET") or os.getenv("MCP_SECRET", "")
secret = secret.strip()
if not secret:
logger.error("MCPSECRET is not configured on the server.")
raise HTTPException(status_code=500, detail="MCPSECRET not configured")
# Perplexity sends 'api-key', others might send 'X-MCP-Secret' or 'Authorization: Bearer ...'
token = request.headers.get("api-key")
if not token:
token = request.headers.get("X-MCP-Secret")
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token or token != secret:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
def _agent_headers() -> dict:
"""Headers for maskin-til-maskin kall videre til osvauco-agent."""
token = google.oauth2.id_token.fetch_id_token(
google.auth.transport.requests.Request(),
os.environ["OSVAUCO_AGENT_URL"],
)
return {
"Authorization": f"Bearer {token}",
"X-Internal-Key": INTERNAL_API_KEY,
"Content-Type": "application/json",
}
async def _agent_get(path: str) -> Any:
"""GET-kall til osvauco-agent."""
url = f"{OSVAUCO_AGENT_URL}{path}"
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(url, headers=_agent_headers())
r.raise_for_status()
return r.json()
async def _agent_post(path: str, body: dict) -> Any:
"""POST-kall til osvauco-agent."""
url = f"{OSVAUCO_AGENT_URL}{path}"
async with httpx.AsyncClient(timeout=45) as c:
try:
r = await c.post(url, json=body, headers=_agent_headers())
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP Status Error for {e.request.url}: {e.response.status_code}")
logger.error(f"Response body: {e.response.text}")
raise
# ---------------------------------------------------------------------------
# Hjelpefunksjoner for sikkerhet og sub-prosesser
# ---------------------------------------------------------------------------
def _is_allowed_remote(url: str) -> bool:
"""
Validerer at en remote URL peker til det tillatte Gitea-repoet,
uavhengig av protokoll (ssh, https).
"""
try:
if url.startswith("git@"):
host_part, path_part = url[4:].split(":", 1)
host = host_part
path = path_part.replace(".git", "")
elif url.startswith(("https://", "http://")):
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.netloc
path = parsed.path.lstrip("/").replace(".git", "")
else:
return False
return host in ALLOWED_REMOTE_HOSTS and path == ALLOWED_REMOTE_PATH
except Exception:
return False
def _mask_secrets(text: str) -> str:
"""Maskerer kjente secrets og generelle token-mønstre i en tekststreng."""
masked_text = text
for pattern in GENERIC_SECRET_PATTERNS:
masked_text = pattern.sub("[MASKED_TOKEN]", masked_text)
for pattern in SECRET_MASK_PATTERNS:
masked_text = pattern.sub("[MASKED_SECRET]", masked_text)
return masked_text
def _validate_path_inside_repo(path_str: str) -> Path:
"""
Validates that a safe relative path is within REPO_ROOT.
"""
# 1. Type and content validation
if not isinstance(path_str, str) or not path_str.strip():
raise ValueError("Path must be a non-empty string.")
# 2. Disallowed characters
if any(char in path_str for char in ('\x00', '\r', '\n')):
raise ValueError("Path cannot contain control characters.")
if '\\' in path_str:
raise ValueError("Path cannot contain backslashes.")
# 3. Disallow absolute paths (Unix and Windows-like)
if path_str.startswith('/') or re.match(r"^[a-zA-Z]:", path_str):
raise ValueError("Path cannot be absolute.")
# 4. Segment validation on the original path string
segments = path_str.split('/')
if any(segment in {'.', '..', '.git'} for segment in segments):
raise ValueError("Path contains a disallowed segment ('.', '..', or '.git').")
# 5. Final resolution and boundary check.
prospective_path = (REPO_ROOT / path_str).resolve()
if not prospective_path.is_relative_to(REPO_ROOT):
raise ValueError("Path resolves outside the repository root.")
return prospective_path
def _run_git(args: list[str], timeout: int, check: bool = True) -> subprocess.CompletedProcess:
for arg in args:
if "\n" in arg or "\r" in arg or "\x00" in arg:
raise ValueError(f"Ugyldig argument (inneholder newline/null): {arg!r}")
try:
command = ["git"] + args
result = subprocess.run(
command, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout, check=check
)
result.stdout = _mask_secrets(result.stdout)
result.stderr = _mask_secrets(result.stderr)
return result
except FileNotFoundError:
raise RuntimeError("Git-kommandoen ble ikke funnet. Er Git installert og i PATH?")
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Git-kommandoen timet ut etter {timeout} sekunder.")
except subprocess.CalledProcessError as e:
e.stdout = _mask_secrets(e.stdout)
e.stderr = _mask_secrets(e.stderr)
raise RuntimeError(f"Git-kommando feilet med exit code {e.returncode}:\n{e.stderr}")
except Exception as e:
raise RuntimeError(f"En uventet feil oppstod under kjøring av Git: {e}")
# ---------------------------------------------------------------------------
# Read-only Git Tools
# ---------------------------------------------------------------------------
async def get_git_status(p: dict) -> dict:
"""Henter status for Git-repoet (porcelain-format)."""
result = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ)
return {"status": "clean" if not result.stdout else "dirty", "details": result.stdout}
async def get_git_branch(p: dict) -> dict:
"""Lister alle lokale og remote branches og viser den nåværende branchen."""
current_branch_res = _run_git(["branch", "--show-current"], timeout=GIT_TIMEOUT_READ)
all_branches_res = _run_git(["branch", "-a"], timeout=GIT_TIMEOUT_READ)
return {
"current_branch": current_branch_res.stdout.strip(),
"all_branches": all_branches_res.stdout.strip().split("\n")
}
async def get_git_log(p: dict) -> dict:
"""Henter Git-loggen (oneline-format)."""
limit = p.get("limit", 10)
if not isinstance(limit, int) or not 1 <= limit <= 100:
limit = 10
result = _run_git(["log", "--oneline", "-n", str(limit)], timeout=GIT_TIMEOUT_READ)
return {"log": result.stdout.strip().split("\n")}
async def get_git_diff(p: dict) -> dict:
"""Viser diff for spesifiserte filer, eller for alle endringer hvis ingen filer er angitt."""
paths = p.get("paths")
args = ["diff"]
if paths:
validated_paths = [_validate_path_inside_repo(path).relative_to(REPO_ROOT) for path in paths]
args.extend([str(p) for p in validated_paths])
result = _run_git(args, timeout=GIT_TIMEOUT_READ, check=False)
return {"diff": result.stdout}
async def read_repo_file(p: dict) -> dict:
"""Leser innholdet i en spesifikk fil i repoet, med masking av secrets."""
path_str = p.get("path")
if not path_str: raise ValueError("Parameter 'path' er påkrevd.")
file_path = _validate_path_inside_repo(path_str)
if not file_path.is_file():
raise FileNotFoundError(f"Filen ble ikke funnet: {path_str}")
content = file_path.read_text(encoding="utf-8")
content = _mask_secrets(content)
start = p.get("start_line")
end = p.get("end_line")
if start and end and isinstance(start, int) and isinstance(end, int) and start > 0 and end >= start:
lines = content.splitlines()
return {"path": path_str, "content": "\n".join(lines[start-1:end])}
return {"path": path_str, "content": content}
async def list_repo_files(p: dict) -> dict:
"""Lister filer og mapper i en gitt sti i repoet."""
recursive = p.get("recursive", False)
if "path" in p:
path_str = p["path"]
# An explicitly provided path must be strictly validated.
start_path = _validate_path_inside_repo(path_str)
else:
# If the 'path' key is missing entirely, default to the repo root.
start_path = REPO_ROOT
path_str = "." # Keep original behavior for the return value
if recursive:
files = [str(f.relative_to(REPO_ROOT)) for f in start_path.rglob('*')]
else:
files = [str(f.relative_to(REPO_ROOT)) for f in start_path.iterdir()]
return {"path": path_str, "files": files}
# ---------------------------------------------------------------------------
# Write Git Tools
# ---------------------------------------------------------------------------
async def preview_git_change(p: dict) -> dict:
"""Forhåndsviser en Git-endring (status, diff, HEAD) før en eventuell commit."""
paths = p.get("paths")
expected_head_sha = p.get("expected_head_sha")
if not paths or not isinstance(paths, list):
raise ValueError("'paths' må være en liste med filstier.")
if not expected_head_sha:
raise ValueError("'expected_head_sha' er påkrevd for å unngå race conditions.")
validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths]
head_res = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ)
current_head_sha = head_res.stdout.strip()
if current_head_sha != expected_head_sha:
raise ValueError(f"HEAD mismatch. Forventet: {expected_head_sha}, Faktisk: {current_head_sha}")
status_res = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ)
untracked_changes = []
for line in status_res.stdout.strip().splitlines():
changed_file = line[3:]
if changed_file not in validated_paths:
untracked_changes.append(line)
if untracked_changes:
raise RuntimeError(f"Repoet har endringer utenfor de angitte stiene: {untracked_changes}")
diff_res = _run_git(["diff"] + validated_paths, timeout=GIT_TIMEOUT_READ, check=False)
return {
"status": "ok",
"current_head_sha": current_head_sha,
"diff": diff_res.stdout,
"message": "Forhåndsvisning generert. Klar for commit_and_push_files."
}
async def commit_and_push_files(p: dict) -> dict:
"""Commiter og pusher filer etter validering. Krever eksplisitt godkjenning i klienten."""
branch, paths, msg, head_sha = p.get("branch"), p.get("paths"), p.get("commit_message"), p.get("expected_head_sha")
if not all([branch, paths, msg, head_sha]):
raise ValueError("Alle parametere (branch, paths, commit_message, expected_head_sha) er påkrevde.")
validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths]
await preview_git_change({"paths": validated_paths, "expected_head_sha": head_sha})
if not re.match(r"^[a-zA-Z0-9/_-]+$", branch):
raise ValueError(f"Ugyldig branch-navn: {branch}")
remote_url = _run_git(["remote", "get-url", "origin"], timeout=GIT_TIMEOUT_READ).stdout.strip()
if not _is_allowed_remote(remote_url):
raise RuntimeError(f"Push nektet: Remote URL '{remote_url}' er ikke tillatt.")
_run_git(["add"] + validated_paths, timeout=GIT_TIMEOUT_WRITE)
diff_cached_res = _run_git(["diff", "--cached", "--quiet"], timeout=GIT_TIMEOUT_WRITE, check=False)
if diff_cached_res.returncode == 0:
raise RuntimeError("Ingen endringer å committe. Avbryter.")
commit_res = _run_git(["commit", "-m", msg], timeout=GIT_TIMEOUT_WRITE)
new_commit_sha = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ).stdout.strip()
push_refspec = f"HEAD:refs/heads/{branch}"
push_res = _run_git(["push", "origin", push_refspec], timeout=GIT_TIMEOUT_WRITE)
return {
"status": "success",
"commit_sha": new_commit_sha,
"old_head_sha": head_sha,
"push_output": push_res.stdout,
"message": f"Filer committet og pushet til branch '{branch}'."
}
# ---------------------------------------------------------------------------
# Google Workspace Helpers
# ---------------------------------------------------------------------------
def _get_workspace_service(api: str, version: str, scopes: list):
"""
Creates an authenticated Google API service object using a service account
key from Secret Manager and impersonating the admin user.
"""
try:
client = secretmanager.SecretManagerServiceClient()
secret_name = "workspace-sa-key"
version_name = f"projects/{GOOGLE_CLOUD_PROJECT}/secrets/{secret_name}/versions/latest"
response = client.access_secret_version(request={"name": version_name})
secret_payload = response.payload.data.decode("UTF-8")
secret_info = json.loads(secret_payload)
creds = service_account.Credentials.from_service_account_info(
secret_info, scopes=scopes
)
delegated_creds = creds.with_subject(WORKSPACE_ADMIN_USER)
return build(api, version, credentials=delegated_creds, cache_discovery=False)
except Exception as e:
logger.error(f"Failed to get Workspace service: {e}", exc_info=True)
raise
async def create_email_alias(p: dict) -> dict:
"""Creates a new email alias for a Google Workspace user."""
user_key = p.get("user_key")
alias_email = p.get("alias")
if not user_key or not alias_email:
raise ValueError("Missing required parameters: 'user_key' and 'alias'")
service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user'])
alias_body = {'alias': alias_email}
logger.info(f"Creating alias '{alias_email}' for user '{user_key}'...")
result = service.users().aliases().insert(userKey=user_key, body=alias_body).execute()
logger.info(f"Successfully created alias.")
return {"status": "success", "alias": result}
async def list_user_aliases(p: dict) -> dict:
"""Lists all aliases for a Google Workspace user."""
user_key = p.get("user_key")
if not user_key:
raise ValueError("Missing required parameter: 'user_key'")
service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user'])
logger.info(f"Listing aliases for user '{user_key}'...")
result = service.users().aliases().list(userKey=user_key).execute()
logger.info(f"Successfully listed aliases.")
return {"status": "success", "aliases": result.get("aliases", [])}
async def delete_email_alias(p: dict) -> dict:
"""Deletes an email alias from a Google Workspace user."""
user_key = p.get("user_key")
alias_email = p.get("alias")
if not user_key or not alias_email:
raise ValueError("Missing required parameters: 'user_key' and 'alias'")
service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user'])
logger.info(f"Deleting alias '{alias_email}' for user '{user_key}'...")
service.users().aliases().delete(userKey=user_key, alias=alias_email).execute()
logger.info(f"Successfully deleted alias.")
return {"status": "success", "detail": f"Alias '{alias_email}' deleted."}
async def send_email_as(p: dict) -> dict:
"""Sends an email as a user or their alias, on their behalf."""
from_address, to_address, subject, body = p.get("from"), p.get("to"), p.get("subject"), p.get("body")
if not all([from_address, to_address, subject, body]):
raise ValueError("Missing required parameters: 'from', 'to', 'subject', 'body'")
service = _get_workspace_service('gmail', 'v1', ['https://www.googleapis.com/auth/gmail.send'])
message = MIMEText(body)
message['to'], message['from'], message['subject'] = to_address, from_address, subject
encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
create_message = {'raw': encoded_message}
logger.info(f"Sending email from '{from_address}' to '{to_address}'...")
send_message = service.users().messages().send(userId='me', body=create_message).execute()
logger.info(f"Successfully sent email with ID: {send_message['id']}")
return {"status": "success", "message_id": send_message['id']}
async def get_workspace_user(p: dict) -> dict:
"""Gets detailed information about a single user in Google Workspace."""
user_key = p.get("user_key")
if not user_key: raise ValueError("Missing required parameter: 'user_key'")
service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user'])
logger.info(f"Getting info for user '{user_key}'...")
user = service.users().get(userKey=user_key).execute()
logger.info(f"Successfully retrieved user info.")
return {"name": user.get("name", {}).get("fullName"), "email": user.get("primaryEmail"), "aliases": user.get("aliases", []), "suspended": user.get("suspended"), "lastLoginTime": user.get("lastLoginTime")}
async def list_workspace_users(p: dict) -> dict:
"""Lists all users in the Google Workspace domain."""
service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user'])
logger.info("Listing all workspace users...")
result = service.users().list(domain='vauco.no', maxResults=50, orderBy='email').execute()
users = result.get('users', [])
logger.info(f"Found {len(users)} users.")
return {"users": [{"email": user.get("primaryEmail"), "name": user.get("name", {}).get("fullName")} for user in users]}
async def get_emails(p: dict) -> dict:
"""Searches a user's mailbox and retrieves a list of emails."""
user_key, query, max_results = p.get('user_key'), p.get('query', ''), p.get('max_results', 10)
if not user_key: raise ValueError("Missing required parameter: 'user_key'")
service = _get_workspace_service('gmail', 'v1', ['https://www.googleapis.com/auth/gmail.modify'])
logger.info(f"Searching emails for user '{user_key}' with query '{query}'...")
list_result = service.users().messages().list(userId=user_key, q=query, maxResults=max_results).execute()
messages = list_result.get('messages', [])
if not messages: return {"messages": []}
email_details = []
for msg in messages:
detail = service.users().messages().get(userId=user_key, id=msg['id'], format='metadata', metadataHeaders=['subject', 'from']).execute()
headers = {h['name']: h['value'] for h in detail['payload']['headers']}
email_details.append({"id": msg['id'], "snippet": detail.get('snippet'), "subject": headers.get('Subject'), "from": headers.get('From')})
logger.info(f"Successfully retrieved {len(email_details)} emails.")
return {"messages": email_details}
async def list_calendar_events(p: dict) -> dict:
"""Lists events from a user's calendar."""
user_key, days_ahead = p.get('user_key'), p.get('days_ahead', 7)
if not user_key: raise ValueError("Missing required parameter: 'user_key'")
service = _get_workspace_service('calendar', 'v3', ['https://www.googleapis.com/auth/calendar'])
logger.info(f"Fetching calendar events for '{user_key}' for the next {days_ahead} days...")
now, time_min = datetime.now(timezone.utc), (datetime.now(timezone.utc)).isoformat()
time_max = (now + timedelta(days=days_ahead)).isoformat()
events_result = service.events().list(calendarId=user_key, timeMin=time_min, timeMax=time_max, singleEvents=True, orderBy='startTime').execute()
events = events_result.get('items', [])
formatted_events = [{"summary": event.get('summary'), "start": event.get('start', {}).get('dateTime', event.get('start', {}).get('date')), "end": event.get('end', {}).get('dateTime', event.get('end', {}).get('date')), "organizer": event.get('organizer', {}).get('email')} for event in events]
logger.info(f"Found {len(formatted_events)} events.")
return {"events": formatted_events}
async def trigger_build(p: dict) -> dict:
"""Triggers a Cloud Build manually by its trigger ID."""
from google.cloud.devtools import cloudbuild_v1
project_id = GOOGLE_CLOUD_PROJECT
trigger_id = p.get("trigger_id")
branch = p.get("branch", "main")
substitutions = p.get("substitutions", {})
if not trigger_id:
raise ValueError("Missing required parameter: 'trigger_id'")
try:
client = cloudbuild_v1.CloudBuildClient()
source = cloudbuild_v1.RepoSource(branch_name=branch, substitutions=substitutions)
response = client.run_build_trigger(
project_id=project_id,
trigger_id=trigger_id,
source=source,
)
build_id = response.metadata.build.id
logger.info(f"Successfully triggered Cloud Build. Build ID: {build_id}")
return {"status": "success", "build_id": build_id}
except Exception as e:
logger.error(f"Failed to trigger Cloud Build: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to trigger Cloud Build: {e}")
# ---------------------------------------------------------------------------
# Ollama helpers — direkte mot emma-gpu-vm
# ---------------------------------------------------------------------------
async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict:
# ... (beholdt uendret)
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
payload = { "model": model, "messages": messages, "stream": False }
try:
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload)
r.raise_for_status()
data = r.json()
return { "model": model, "response": data.get("message", {}).get("content", ""), "done": data.get("done", True), "total_duration_ms": round(data.get("total_duration", 0) / 1e6) }
except Exception as e:
logger.error(f"[OLLAMA ERROR] model={model} {type(e).__name__}: {e}")
raise
async def _ollama_models() -> list:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{OLLAMA_BASE_URL}/api/tags")
r.raise_for_status()
return r.json().get("models", [])
# ---------------------------------------------------------------------------
# Gitea helpers
# ---------------------------------------------------------------------------
def _gitea_headers() -> dict:
return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json", "Accept": "application/json"}
async def _gitea_get(path: str) -> Any:
# ... (beholdt uendret)
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers())
r.raise_for_status()
return r.json()
async def _gitea_post(path: str, body: dict) -> Any:
# ... (beholdt uendret)
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
r.raise_for_status()
return r.json()
async def _gitea_put(path: str, body: dict) -> Any:
# ... (beholdt uendret)
async with httpx.AsyncClient(timeout=30) as c:
r = await c.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# Tool implementations (refaktorert til å bruke _agent_get/_agent_post)
# ---------------------------------------------------------------------------
async def get_billing_summary(p): return await _agent_get("/billing/summary")
async def get_billing_forecast(p): return await _agent_get("/billing/forecast")
async def get_billing_credits(p): return await _agent_get("/billing/credits")
async def get_billing_anomalies(p): return await _agent_get("/billing/anomalies")
async def get_billing_history(p): return await _agent_get("/billing/history")
async def get_billing_budget(p): return await _agent_get("/billing/budget")
async def get_billing_tokens_by_module(p): return await _agent_get("/telemetry/history")
async def set_billing_budget(p): return await _agent_post("/billing/budget", {"budget": p.get("amount", 500)})
async def create_invite(p):
return await _agent_post("/onboard/invite", {
"company": p.get("company", p.get("name", "")), "email": p.get("email"), "tier": p.get("tier", "starter"),
})
async def list_customers(p): return await _agent_get("/state")
async def send_webhook(p):
return await _agent_post("/notify/webhook", {
"url": p.get("url", ""), "event": p.get("event", "custom"),
"title": p.get("title", "OPAX varsel"), "body": p.get("message", p.get("body", "")),
})
async def send_email(p): return await _agent_post("/notify/email", {"to": p.get("to"), "subject": p.get("subject"), "event": p.get("event", "digest")})
async def send_sms(p): return await _agent_post("/notify/sms", {"to": p.get("to"), "body": p.get("message", p.get("body", "")), "event": p.get("event", "spike"), "tier": p.get("tier", "guard")})
async def get_notify_channels(p): return await _agent_get("/notify/channels")
async def get_health(p): return await _agent_get("/health")
async def get_build_status(p): return await _agent_get("/opax/build-status")
async def get_state(p): return await _agent_get("/state")
async def get_telemetry(p): return await _agent_get("/telemetry/history")
async def tui_command(p): return await _agent_post("/tui-command", p)
# --- Uendrede funksjoner ---
async def run_jason(p):
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk."""
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
async def run_emma(p): return await _ollama_chat(EMMA_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er Emma Vauger..."))
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
async def run_qwen(p): return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", "")))
async def list_emma_models(p): return await _ollama_models()
async def list_commits(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/commits?limit={p.get('limit', 10)}")
async def get_file(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/contents/{p.get('path', '')}?ref={p.get('ref', 'main')}")
async def list_open_issues(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/issues?state=open&limit=20")
async def create_issue(p): return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")})
async def create_a2h2a_ticket(p: dict) -> dict:
preview = {
"source": p.get("source"),
"proposed_action": p.get("proposed_action"),
"context": p.get("context"),
"severity": p.get("severity"),
"governance": p.get("governance"),
"dry_run": bool(p.get("dry_run")),
}
if preview["dry_run"]:
return {
"dry_run": True,
"message": "Ticket preview only; no ticket was created.",
"proposed_ticket": preview,
}
from google.cloud import firestore
import uuid, hashlib, hmac, os
db = firestore.Client(project="propane-will-491900-m5")
ticket_id = p.get("ticket_id") or f"a2h2a-{uuid.uuid4().hex[:8]}"
# Generate review token
secret = os.getenv("MCPSECRET", "")
review_token = hmac.new(secret.encode(), ticket_id.encode(), hashlib.sha256).hexdigest()[:32]
review_url = f"https://opax-mcp-zjbqp3prqq-uc.a.run.app/a2h2a/review/{ticket_id}?token={review_token}"
# Persist to Firestore
db.collection("a2h2a_tickets").document(ticket_id).set({
"ticket_id": ticket_id,
"source": p.get("source", {}),
"proposed_action": p.get("proposed_action", {}),
"context": p.get("context", {}),
"severity": p.get("severity", "MEDIUM"),
"governance": p.get("governance", {}),
"status": "PENDING",
"review_token": review_token,
"review_url": review_url,
"created_at": firestore.SERVER_TIMESTAMP,
})
return {
"status": "created",
"ticket_id": ticket_id,
"review_url": review_url,
"message": "A2H2A ticket created. Awaiting approval."
}
async def push_file(p):
# ... (beholdt uendret)
repo, path = p.get("repo", GITEA_REPO), p.get("path")
content = base64.b64encode(p.get("content", "").encode()).decode()
body = {"message": p.get("message", f"chore: update {path} via opax-mcp"), "content": content, "branch": p.get("branch", "main")}
if not p.get("sha"):
try:
existing = await _gitea_get(f"/repos/{repo}/contents/{path}?ref={p.get('branch','main')}")
body["sha"] = existing["sha"]
except Exception: pass
else: body["sha"] = p["sha"]
if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body)
return await _gitea_post(f"/repos/{repo}/contents/{path}", body)
# ---------------------------------------------------------------------------
# Tool registry + MCP schema
# ---------------------------------------------------------------------------
TOOLS = {
# --- Read-only Git Tools ---
"get_git_status": (get_git_status, "Henter status for Git-repoet (porcelain-format)", {}),
"get_git_branch": (get_git_branch, "Lister branches og viser nåværende branch", {}),
"get_git_log": (get_git_log, "Henter Git-loggen", {"type":"object", "properties": {"limit": {"type": "integer"}}}),
"get_git_diff": (get_git_diff, "Viser diff for endringer", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}}}),
"read_repo_file": (read_repo_file, "Leser en fil fra repoet", {"type":"object", "properties": {"path": {"type": "string"}, "start_line": {"type": "integer"}, "end_line": {"type": "integer"}}, "required": ["path"]}),
"list_repo_files": (list_repo_files, "Lister filer og mapper i repoet", {"type":"object", "properties": {"path": {"type": "string"}, "recursive": {"type": "boolean"}}}),
# --- Write Git Tools (krever godkjenning) ---
"preview_git_change": (preview_git_change, "Forhåndsviser en Git-endring før commit", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "expected_head_sha": {"type": "string"}}, "required": ["paths", "expected_head_sha"]}),
"commit_and_push_files": (commit_and_push_files, "Commiter og pusher filer til Git", {"type":"object", "properties": {"branch": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}}, "commit_message": {"type": "string"}, "expected_head_sha": {"type": "string"}}, "required": ["branch", "paths", "commit_message", "expected_head_sha"]}),
# Billing
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
"get_billing_forecast": (
get_billing_forecast,
"Get billing forecast",
{},
),
"get_billing_credits": (
get_billing_credits,
"Get available billing credits",
{},
),
"get_billing_anomalies": (
get_billing_anomalies,
"Get billing anomalies",
{},
),
"get_billing_history": (
get_billing_history,
"Get billing history",
{},
),
"get_billing_budget": (
get_billing_budget,
"Get the configured billing budget",
{},
),
"get_telemetry": (
get_billing_tokens_by_module,
"Get token and telemetry history by module",
{},
),
"set_billing_budget": (set_billing_budget, "Sett månedlig budsjett", {"type":"object","properties":{"amount":{"type":"number"}}}),
# Onboarding
"create_invite": (create_invite, "Inviter ny kunde til OPAX", {"type":"object","properties":{"company":{"type":"string"},"email":{"type":"string"},"tier":{"type":"string"}},"required":["email"]}),
# Notifications
"send_webhook": (send_webhook, "Send webhook-varsling", {"type":"object","properties":{"url":{"type":"string"},"message":{"type":"string"}}}),
"send_email": (send_email, "Send e-post via ekstern tjeneste", {"type":"object","properties":{"to":{"type":"string"},"subject":{"type":"string"}},"required":["to","subject"]}),
"send_sms": (send_sms, "Send SMS", {"type":"object","properties":{"to":{"type":"string"},"message":{"type":"string"}},"required":["to","message"]}),
"get_notify_channels": (
get_notify_channels,
"List configured notification channels",
{},
),
# AI Agents
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
"run_emma": (run_emma, "Emma Vauger (gemma3:27b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}),
# Local models — read-only discovery
"list_emma_models": (
list_emma_models,
"List locally available OSVx model names",
{},
),
# System & Ops
"get_health": (get_health, "Hent helsestatus for OPAX", {}),
"get_build_status": (get_build_status, "Hent siste build-status", {}),
"trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}}}),
"get_state": (get_state, "Hent platform-tilstand", {}),
"list_customers": (list_customers, "List alle kunder (alias for get_state)", {}),
# Gitea / VCS
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
# Google Workspace
"create_email_alias": (create_email_alias, "Opprett et nytt e-postalias", {"type":"object","properties":{"user_key":{"type":"string"},"alias":{"type":"string"}},"required":["user_key","alias"]}),
"list_user_aliases": (list_user_aliases, "List en brukers e-postaliaser", {"type":"object","properties":{"user_key":{"type":"string"}},"required":["user_key"]}),
"delete_email_alias": (delete_email_alias, "Slett et e-postalias", {"type":"object","properties":{"user_key":{"type":"string"},"alias":{"type":"string"}},"required":["user_key","alias"]}),
"send_email_as": (send_email_as, "Send en e-post på vegne av en bruker/alias", {"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"}},"required":["from","to","subject","body"]}),
"get_workspace_user": (get_workspace_user, "Hent detaljer for en Workspace-bruker", {"type":"object","properties":{"user_key":{"type":"string"}},"required":["user_key"]}),
"list_workspace_users": (list_workspace_users, "List alle brukere i Workspace-domenet", {}),
"get_emails": (get_emails, "Søk i en brukers e-post", {"type":"object","properties":{"user_key":{"type":"string"},"query":{"type":"string"}},"required":["user_key"]}),
"list_calendar_events": (list_calendar_events, "List en brukers kalenderhendelser", {"type":"object","properties":{"user_key":{"type":"string"},"days_ahead":{"type":"integer"}},"required":["user_key"]}),
"create_a2h2a_ticket": (
create_a2h2a_ticket,
"Creates an Agent-to-Human-to-Agent (A2H2A) governance ticket for system actions requiring approval.",
{
"type": "object",
"properties": {
"ticket_id": {"type": "string"},
"source": {"type": "object"},
"proposed_action": {"type": "object"},
"context": {"type": "object"},
"severity": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]},
"governance": {"type": "object"}
},
"required": ["source", "proposed_action", "context", "governance"]
}
),
}
# --- Validate allowlist on startup ---
# Fail closed if the configured web agent allowlist contains tools that
# do not exist in the main tool registry.
for tool in WEB_AGENT_ALLOWED_TOOLS:
if tool not in TOOLS:
raise RuntimeError("Configuration error: Invalid web-agent tool policy.")
# ---------------------------------------------------------------------------
# JSON-RPC Endpoint
# ---------------------------------------------------------------------------
def _jsonrpc_ok(req_id, result):
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _jsonrpc_err(req_id, code, message):
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
@app.post("/")
async def mcp_handler(request: Request):
await _verify_auth(request)
try:
body = await request.json()
except Exception: return JSONResponse(_jsonrpc_err(None, -32700, "Parse error"), status_code=400)
method, req_id, params = body.get("method", ""), body.get("id"), body.get("params", {})
if method == "initialize":
return JSONResponse(_jsonrpc_ok(req_id, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},"serverInfo": {"name": "opax-mcp", "version": "3.5.0"}})) # BUMPED
if method == "tools/list":
return JSONResponse(_jsonrpc_ok(req_id, {"tools": [
{"name": name, "description": desc, "inputSchema": schema if schema else {"type": "object", "properties": {}}}
for name, (_, desc, schema) in TOOLS.items()
]}))
if method == "tools/call":
tool_name = params.get("name") or params.get("tool")
if not tool_name or tool_name not in WEB_AGENT_ALLOWED_TOOLS:
return JSONResponse(
_jsonrpc_err(req_id, -32601, "Tool not allowed or not found."),
)
entry = TOOLS.get(tool_name)
if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, "Tool not allowed or not found."))
handler, _, _ = entry
try:
tool_args = params.get("arguments", params.get("params", {}))
result = await handler(tool_args)
return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}))
except Exception as e:
logger.error(
"[MCP HANDLER ERROR] tool=%s error=%s",
tool_name,
type(e).__name__,
exc_info=True,
)
return JSONResponse(
_jsonrpc_err(
req_id,
-32000,
"An internal server error occurred.",
),
)
if method.startswith("notifications/"): return JSONResponse(status_code=202, content={})
return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404)
# ---------------------------------------------------------------------------
# Health (keepalive)
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "service": "opax-mcp", "version": "3.5.0", "ollama": OLLAMA_BASE_URL} # BUMPED