"""opax-mcp — MCP Streamable HTTP server for OPAX/Vauco Transport: MCP Streamable HTTP (JSON-RPC 2.0) on POST / Auth: Authorization: Bearer OR X-MCP-Secret: OR api-key: """ import os import json 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 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[0] 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) # --------------------------------------------------------------------------- # Health (keepalive) # --------------------------------------------------------------------------- @app.get("/health") async def health(): return {"status": "ok", "service": "opax-mcp", "version": "3.5.0", "ollama": OLLAMA_BASE_URL} # BUMPED