732 lines
36 KiB
Python
732 lines
36 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",
|
|
})
|
|
|
|
# --- 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", "qwen2.5:7b")
|
|
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}")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = {
|
|
|
|
# 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 (qwen2.5:7b) — 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
|