OSVauco/opax-mcp/server.py
Gemini Agent a9cb707a6a
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
fix(opax-mcp): use OIDC token for agent authentication
2026-07-21 10:24:46 +00:00

701 lines
41 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
# Absolute import for local handlers
from gitea_handler import handle_get_file_content, handle_list_repo_files
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.170.51.84: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)
# ---------------------------------------------------------------------------
def _verify_auth(request: Request):
"""Sjekker at innkommende kall til DENNE tjenesten (opax-mcp) er autentisert."""
if not MCP_SECRET:
logger.warning("MCP_SECRET is not set, skipping auth verification.")
return
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:] == MCP_SECRET:
return
if request.headers.get("X-MCP-Secret") == MCP_SECRET:
return
if request.headers.get("api-key") == MCP_SECRET:
return
raise HTTPException(status_code=401, detail="Unauthorized")
# ---------------------------------------------------------------------------
# Backend Agent helpers (for kall VIDERE til osvauco-agent)
# ---------------------------------------------------------------------------
def _get_oidc_token(audience: str) -> str:
"""Fetches an OIDC token for a given audience."""
try:
# First, try the metadata server (for VM/Cloud Run environment)
token_url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}&format=full"
r = httpx.get(token_url, headers={"Metadata-Flavor": "Google"})
if r.is_success:
return r.text
# Fallback to application default credentials (for local dev)
logging.info("Metadata server failed, falling back to ADC for OIDC token.")
creds, project = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
auth_req = google.auth.transport.requests.Request()
creds.refresh(auth_req)
id_token = google.oauth2.id_token.fetch_id_token(auth_req, audience)
return id_token
except Exception as e:
logging.error(f"Failed to get OIDC token for audience {audience}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Could not obtain OIDC token for backend service. Error: {e}")
def _agent_headers() -> dict:
"""Headers for M2M calls to osvauco-agent, authenticated with OIDC token."""
# Ensure audience is the base URL, without any path
audience = OSVAUCO_AGENT_URL.split('/')[0] + '//' + OSVAUCO_AGENT_URL.split('/')[2]
token = _get_oidc_token(audience=audience)
return {
"Authorization": f"Bearer {token}",
"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:
r = await c.post(url, json=body, headers=_agent_headers())
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# 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:
"""Trigger en Cloud Build manuelt ved å sende kildekode fra Gitea."""
import subprocess
repo = p.get("repo", "OSVauco")
branch = p.get("branch", "main")
config = p.get("config", "cloudbuild.mcp.yaml")
result = subprocess.run([
"gcloud", "builds", "submit",
f"/home/chris_christiansen/OSVauco",
f"--config={config}",
f"--project={GOOGLE_CLOUD_PROJECT}"
], capture_output=True, text=True)
if result.returncode != 0:
error_message = result.stderr.strip()
logger.error(f"Cloud Build trigger failed: {error_message}")
return {"status": "error", "message": error_message}
build_id = "Not found in output"
for line in result.stdout.split('\n'):
if "ID:" in line:
build_id = line.split("ID:")[1].strip()
break
return {"status": "triggered", "build_id": build_id}
# ---------------------------------------------------------------------------
# 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 run_terminal(p): return await _agent_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))})
async def tui_command(p): return await _agent_post("/tui-command", p)
# --- Gitea delegators ---
async def get_file_content(p):
"""Delegates to the gitea_handler to get file content."""
return await handle_get_file_content(p, default_repo=GITEA_REPO)
async def list_repo_files(p):
"""Delegates to the gitea_handler to list repo files."""
return await handle_list_repo_files(p, default_repo=GITEA_REPO)
# --- 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 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)
async def describe_service(p: dict) -> dict:
"""Describes a Cloud Run service."""
import subprocess
service = p.get("service", "opax-mcp")
region = p.get("region", "us-central1")
result = subprocess.run(
["gcloud", "run", "services", "describe", service, f"--region={region}", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
return json.loads(result.stdout)
async def list_gce_instances(p: dict) -> dict:
"""Lists GCE instances in the project and adds action choices."""
import subprocess
import json
result = subprocess.run(
["gcloud", "compute", "instances", "list", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
instances = json.loads(result.stdout)
for instance in instances:
instance['choices'] = []
zone = instance['zone'].split('/')[-1]
if instance['status'] == 'RUNNING':
instance['choices'].append({
"id": f"tool_call:stop_gce_instance:instance={instance['name']},zone={zone}",
"label": "Stopp VM",
"style": "danger"
})
elif instance['status'] == 'TERMINATED':
instance['choices'].append({
"id": f"tool_call:start_gce_instance:instance={instance['name']},zone={zone}",
"label": "Start VM",
"style": "primary"
})
return {"instances": instances}
async def list_service_revisions(p: dict) -> dict:
"""Lists revisions for a Cloud Run service."""
import subprocess
service = p.get("service", "opax-mcp")
region = p.get("region", "us-central1")
result = subprocess.run(
["gcloud", "run", "revisions", "list", f"--service={service}", f"--region={region}", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
return {"revisions": json.loads(result.stdout)}
async def get_cloud_run_logs(p: dict) -> dict:
"""Gets logs for a Cloud Run service."""
import subprocess
service = p.get("service", "opax-mcp")
limit = p.get("limit", "50")
result = subprocess.run(
["gcloud", "logging", "read", f'resource.type="cloud_run_revision" AND resource.labels.service_name="{service}"', f"--project={GOOGLE_CLOUD_PROJECT}", f"--limit={limit}", "--format=json"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
return {"logs": json.loads(result.stdout)}
async def list_builds(p: dict) -> dict:
"""Lists the latest Cloud Builds."""
import subprocess
limit = p.get("limit", "20")
result = subprocess.run(
["gcloud", "builds", "list", f"--project={GOOGLE_CLOUD_PROJECT}", f"--limit={limit}", "--format=json"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
return {"builds": json.loads(result.stdout)}
async def get_build_log(p: dict) -> dict:
"""Gets the log for a specific Cloud Build."""
import subprocess
build_id = p.get("build_id")
if not build_id:
raise ValueError("Missing required parameter: 'build_id'")
result = subprocess.run(
["gcloud", "builds", "log", build_id, f"--project={GOOGLE_CLOUD_PROJECT}"],
capture_output=True, text=True, check=False
)
if result.returncode != 0:
return {"error": result.stderr}
return {"log": result.stdout}
async def get_connector_status(p: dict) -> tuple:
"""Gir en helsesjekk av konnektoren, inkludert Gitea-status og antall verktøy."""
gitea_ok = False
try:
await _gitea_get("/version")
gitea_ok = True
except Exception:
gitea_ok = False
status_text = f"OPAX-MCP: Live\nGitea: {'Online' if gitea_ok else 'Offline'}\nTools: {len(TOOLS)} tilgjengelig"
raw_data = {"opax_mcp_status": "live", "gitea_status": "online" if gitea_ok else "offline", "tool_count": len(TOOLS)}
custom_meta = {"status": status_text, "next_action": "Klar for kommandoer.", "choices": [{"id": "list_tools", "label": "List alle verktøy", "style": "primary"}]}
return (raw_data, custom_meta)
async def start_gce_instance(p: dict) -> tuple:
"""Starter en GCE-instans, med to-trinns bekreftelse."""
import subprocess
instance, zone, confirm = p.get("instance"), p.get("zone", "us-central1-b"), p.get("confirm", False)
if not instance: raise ValueError("Mangler 'instance'-parameter.")
if not confirm:
raw_data = {"action": "confirm_start", "instance": instance, "zone": zone}
custom_meta = {"status": f"Ønsker du å starte '{instance}'?", "next_action": "Instansen vil bli skrudd på.", "choices": [{"id": f'tool_call:start_gce_instance:instance={instance},zone={zone},confirm=true', "label": f"Ja, start '{instance}'", "style": "danger"}, {"id": "cancel", "label": "Avbryt", "style": "secondary"}]}
return (raw_data, custom_meta)
result = subprocess.run(["gcloud", "compute", "instances", "start", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Feil", "next_action": "Sjekk instans-navn og rettigheter."})
raw_data = {"status": f"Start-kommando sendt for {instance}."}
custom_meta = {"status": f"Start-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."}
return (raw_data, custom_meta)
async def stop_gce_instance(p: dict) -> tuple:
"""Stopper en GCE-instans, med to-trinns bekreftelse."""
import subprocess
instance, zone, confirm = p.get("instance"), p.get("zone", "us-central1-b"), p.get("confirm", False)
if not instance: raise ValueError("Mangler 'instance'-parameter.")
if not confirm:
raw_data = {"action": "confirm_stop", "instance": instance, "zone": zone}
custom_meta = {"status": f"ADVARSEL: Stoppe '{instance}'?", "next_action": "Dette skrur av den virtuelle maskinen.", "choices": [{"id": f'tool_call:stop_gce_instance:instance={instance},zone={zone},confirm=true', "label": f"Ja, stopp '{instance}'", "style": "danger"}, {"id": "cancel", "label": "Avbryt", "style": "secondary"}]}
return (raw_data, custom_meta)
result = subprocess.run(["gcloud", "compute", "instances", "stop", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Feil", "next_action": "Sjekk instans-navn og rettigheter."})
raw_data = {"status": f"Stopp-kommando sendt for {instance}."}
custom_meta = {"status": f"Stopp-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."}
return (raw_data, custom_meta)
# ---------------------------------------------------------------------------
# Tool registry + MCP schema
# ---------------------------------------------------------------------------
TOOLS = {
# Billing
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
"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"]}),
# 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"}}}),
"list_emma_models": (list_emma_models, "List tilgjengelige Emma-modeller på Ollama", {}),
# 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)", {}),
"run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}),
"get_connector_status": (get_connector_status, "Hent en rask helsestatus for konnektoren", {}),
# Ops / GCE
"describe_service": (describe_service, "Hent detaljer for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}}}),
"list_gce_instances": (list_gce_instances, "List GCE-instanser i prosjektet", {}),
"start_gce_instance": (start_gce_instance, "Start en GCE-instans (krever bekreftelse)", {"type":"object","properties":{"instance":{"type":"string"}, "zone":{"type":"string"}, "confirm":{"type":"boolean"}},"required":["instance"]}, {"destructiveHint": True, "readOnlyHint": False, "idempotentHint": False, "openWorldHint": True}),
"stop_gce_instance": (stop_gce_instance, "Stopp en GCE-instans (krever bekreftelse)", {"type":"object","properties":{"instance":{"type":"string"}, "zone":{"type":"string"}, "confirm":{"type":"boolean"}},"required":["instance"]}, {"destructiveHint": True, "readOnlyHint": False, "idempotentHint": False, "openWorldHint": True}),
# Ops / Deploy & Logs
"list_service_revisions": (list_service_revisions, "List revisjoner for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}}}),
"get_cloud_run_logs": (get_cloud_run_logs, "Hent logger for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}, "limit":{"type":"string"}}}),
"list_builds": (list_builds, "List de siste Cloud Builds", {"type":"object","properties":{"limit":{"type":"string"}}}),
"get_build_log": (get_build_log, "Hent loggen for en spesifikk Cloud Build", {"type":"object","properties":{"build_id":{"type":"string"}},"required":["build_id"]}),
# 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"]}),
"get_file_content": (get_file_content, "Hent innholdet i en fil fra Gitea (ny handler)", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
"list_repo_files": (list_repo_files, "List filer og mapper i Gitea (ny handler)", {"type":"object","properties":{"path":{"type":"string"}}}),
"push_file": (push_file, "Opprett eller oppdater fil i Gitea-repo", {"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}),
# 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"]}),
}
# ---------------------------------------------------------------------------
# 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):
# ... (beholdt uendret)
_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":
tools_list = []
for name, tool_tuple in TOOLS.items():
# Støtter både (handler, desc, schema) og (handler, desc, schema, annotations)
if len(tool_tuple) == 4:
_, desc, schema, annotations = tool_tuple
else:
_, desc, schema = tool_tuple
annotations = None
tool_entry = {
"name": name, "description": desc,
"inputSchema": schema if schema else {"type": "object", "properties": {}}
}
if annotations: # Legg kun til feltet hvis det finnes data
tool_entry["annotations"] = annotations
tools_list.append(tool_entry)
return JSONResponse(_jsonrpc_ok(req_id, {"tools": tools_list}))
if method == "tools/call":
tool_name, tool_args = params.get("name") or params.get("tool"), params.get("arguments", params.get("params", {}))
entry = TOOLS.get(tool_name)
if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}"))
handler, _, _ = entry
try:
handler_result = await handler(tool_args)
# Håndter både enkle returverdier og (data, meta) tupler
if isinstance(handler_result, tuple) and len(handler_result) == 2:
raw_data, custom_meta = handler_result
else:
raw_data = handler_result
custom_meta = {
"status": "Vellykket.",
"next_action": "Se over data og fortsett.",
"choices": [{"id": "continue", "label": "Fortsett", "style": "primary"}]
}
meta_part = {"meta": custom_meta}
# Bevar original 'content' for bakoverkompatibilitet
content_part = {"content": [{"type": "text", "text": json.dumps(raw_data, ensure_ascii=False)}]}
return JSONResponse(_jsonrpc_ok(req_id, {**meta_part, **content_part}))
except Exception as e:
logger.error(f"[MCP HANDLER ERROR] tool={tool_name} {type(e).__name__}: {e}", exc_info=True)
return JSONResponse(_jsonrpc_err(req_id, -32000, str(e)))
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)
async def start_gce_instance(p: dict) -> tuple:
"""Starts a GCE instance. Assumes us-central1-b if zone is not specified."""
import subprocess
instance = p.get("instance")
zone = p.get("zone", "us-central1-b") # Default zone assumption
if not instance: raise ValueError("Missing required parameter: 'instance'")
result = subprocess.run(["gcloud", "compute", "instances", "start", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Error", "next_action": "Check instance name and permissions."})
raw_data = {"status": f"Start command issued for {instance}."}
custom_meta = {"status": f"Start-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`.", "choices": [{"id": f"list_gce_instances", "label": "Sjekk status nå", "style": "primary"}]}
return (raw_data, custom_meta)
async def stop_gce_instance(p: dict) -> tuple:
"""Stops a GCE instance. Assumes us-central1-b if zone is not specified."""
import subprocess
instance = p.get("instance")
zone = p.get("zone", "us-central1-b") # Default zone assumption
if not instance: raise ValueError("Missing required parameter: 'instance'")
result = subprocess.run(["gcloud", "compute", "instances", "stop", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Error", "next_action": "Check instance name and permissions."})
raw_data = {"status": f"Stop command issued for {instance}."}
custom_meta = {"status": f"Stopp-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`.", "choices": [{"id": f"list_gce_instances", "label": "Sjekk status nå", "style": "primary"}]}
return (raw_data, custom_meta)
# ---------------------------------------------------------------------------
# Health (keepalive)
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "service": "opax-mcp", "version": "3.5.0", "ollama": OLLAMA_BASE_URL} # BUMPED