"""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 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, Form from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware import asyncio import secrets # --- OPAX Imports --- from persistence.firestore_store import FirestoreMemoryStore from persistence.memory_store import MemoryStore from contracts.common import ( CallerContext, CallEmmaRequest, CallEmmaResponse, AuditEvent as EmmaAuditEvent, ) from policy.caller_context import derive_caller_context from persistence.memory_scope import calculate_effective_memory_scope # --- Tool Imports (with fault tolerance) --- try: from tyr.tools.scan_tyr_surface import scan_tyr_surface except Exception as e: print(f"Failed to load scan_tyr_surface: {e}") scan_tyr_surface = None try: from tyr.tools.eval_tyr_identity import eval_tyr_identity except Exception as e: print(f"Failed to load eval_tyr_identity: {e}") eval_tyr_identity = None try: from tyr.tools.get_tyr_forecast import get_tyr_forecast except Exception as e: print(f"Failed to load get_tyr_forecast: {e}") get_tyr_forecast = None try: from tyr.tools.get_tyr_user_risk import get_tyr_user_risk except Exception as e: print(f"Failed to load get_tyr_user_risk: {e}") get_tyr_user_risk = None try: from modules.factory.tools.provision_new_mcp_module import provision_new_mcp_module except Exception as e: print(f"Failed to load provision_new_mcp_module: {e}") provision_new_mcp_module = None from emma_adapter import CanonicalEmma from gitea_handler import handle_list_repo_files, list_allowed_namespace_repositories, handle_get_file_content from capability_bridge import build_capability_system_context from email.mime.text import MIMEText from datetime import datetime, timezone, timedelta from typing import Any, Optional, Dict, List import logging from pydantic import BaseModel, Field import hashlib # --- A2H2A Pydantic Models --- class TicketSource(BaseModel): reporter: str trigger: str affected_service: str project_id: str region: str class TicketContext(BaseModel): summary: str evidence_logs: List[str] class ProposedAction(BaseModel): action_type: str runbook_reference: str execution_tool: str parameters: Dict parameter_hash: Optional[str] = None rollback_plan: str class Governance(BaseModel): approval_status: str = "PENDING" authorized_approver: str requires_mfa: bool = True timeout_minutes: int = 15 approval_token_hash: Optional[str] = None csrf_token_hash: Optional[str] = None approved_at: Optional[datetime] = None rejected_at: Optional[datetime] = None verified_approver_email: Optional[str] = None token_used_at: Optional[datetime] = None class AuditEvent(BaseModel): event_id: str = Field(default_factory=lambda: str(uuid.uuid4())) ticket_id: str timestamp: datetime = Field(default_factory=datetime.utcnow) event_type: str # e.g., PROPOSAL_CREATED, TICKET_APPROVED, TICKET_REJECTED actor: str details: Dict[str, Any] class A2H2ATicket(BaseModel): ticket_id: str timestamp: datetime severity: str # CRITICAL, HIGH, MEDIUM, LOW category: str source: TicketSource context: TicketContext proposed_action: ProposedAction governance: Governance def calculate_parameter_hash(self) -> str: """Beregn SHA-256 hash av parameters i kanonisk form.""" param_json = json.dumps(self.proposed_action.parameters, sort_keys=True, separators=(",", ":")) return hashlib.sha256(param_json.encode()).hexdigest() def is_expired(self) -> bool: """Sjekk om ticket er utlΓΈpt""" from datetime import datetime, timedelta expiry = self.timestamp + timedelta(minutes=self.governance.timeout_minutes) return datetime.utcnow() > expiry.replace(tzinfo=None) 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.6.0") # BUMPED app.add_middleware( CORSMiddleware, allow_origins=["https://opax-mcp-357036551735.us-central1.run.app"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Metrics --- A2H2A_METRICS = { "tickets_created": 0, "tickets_approved": 0, "tickets_rejected": 0, "tickets_expired": 0, } @app.get("/metrics") async def get_metrics(): return A2H2A_METRICS @app.get("/") async def root_get(): return {"status": "healthy", "service": "OPAX MCP Server", "version": "3.6.0"} async def sse_event_stream(request: Request): """Yields server-sent events for MCP protocol.""" # MCP requires an immediate connect event yield f"id: {uuid.uuid4()}\nevent: connect\ndata: {json.dumps({'protocolVersion': '2024-11-05'})}\n\n" while True: # Send keepalive pings every 15s yield f"id: {uuid.uuid4()}\nevent: ping\ndata: {json.dumps({'time': datetime.now(timezone.utc).isoformat()})}\n\n" await asyncio.sleep(15) @app.get("/sse") async def sse_endpoint(request: Request): """Handles Server-Sent Events for external clients like Perplexity.""" return StreamingResponse(sse_event_stream(request), media_type="text/event-stream") # --- A2H2A API Endpoints --- # --- A2H2A Secure Design Placeholder --- # # The following logic is a placeholder for a secure A2H2A (Agent-to-Human-to-Agent) # approval workflow. The key principles are: # # 1. NOTIFICATION, NOT ACTION: Google Chat messages are for notification only. They # do not carry any identity or authorization guarantee. # 2. VERIFIED IDENTITY: Human approval must happen through a protected surface that # verifies the user's identity (e.g., a web app protected by Google IAP). # 3. ATOMIC, IMMUTABLE ACTIONS: The ticket stored in the backend (Firestore) is the # source of truth. The proposed action, its parameters, and the server-generated # parameter hash are immutable. # 4. ONE-TIME USE TOKENS: The approval UI should be accessed via a link containing a # cryptographically random, single-use token that is invalidated after the first # approval or rejection, or after the ticket's TTL expires. # 5. DECOUPLED EXECUTION: The approval action only changes the ticket's state to # 'APPROVED'. A separate, secure worker process will poll for approved tickets and # execute them, ensuring a clean separation of concerns. This worker validates the # ticket's integrity (non-expired, parameter hash match) before execution. # # This implementation only covers the safe recording of a proposed ticket. # Execution is explicitly disabled. # A strict allow-list of tools that can be proposed in an A2H2A ticket. # `execution_enabled: False` means the tool can be proposed but not executed. A2H2A_TOOL_ALLOW_LIST = { "example_tool_1": {"execution_enabled": False, "description": "A sample tool that does something harmless."}, "example_tool_2": {"execution_enabled": False, "description": "Another sample tool."}, # Add real, vetted tools here with execution_enabled: True ONLY after extensive security review. } async def _create_audit_event(db, event: AuditEvent): """Stores an audit event in Firestore.""" event_ref = db.collection('a2h2a_audit_events').document(event.event_id) await event_ref.set(event.model_dump(mode='json')) logger.info(f"Created audit event {event.event_type} for ticket {event.ticket_id}") @app.post("/api/v1/a2h2a/tickets") async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request, test_mode: bool = False): """ Opprett ny A2H2A ticket og send til Google Chat for godkjenning. Dette er kun en prototype for sikker registrering, ingen verktΓΈy blir kjΓΈrt. """ try: if not test_mode: await _verify_auth(request) # Security check: Reject if caller provides a parameter_hash. if ticket.proposed_action.parameter_hash is not None: raise HTTPException(status_code=400, detail="Do not provide a parameter_hash. The server will generate it.") # Security check: Ensure the proposed tool is in the allow-list. tool_name = ticket.proposed_action.execution_tool if tool_name not in A2H2A_TOOL_ALLOW_LIST: raise HTTPException(status_code=400, detail=f"Tool '{tool_name}' is not on the approved A2H2A allow-list.") # Generate parameter hash server-side for integrity. ticket.proposed_action.parameter_hash = ticket.calculate_parameter_hash() # Generate a cryptographically secure, single-use approval token. # The raw token is returned once in the review URL and never persisted. # Only its SHA-256 hash is stored server-side. raw_token = secrets.token_hex(32) token_hash = hashlib.sha256(raw_token.encode()).hexdigest() ticket.governance.approval_token_hash = token_hash from google.cloud import firestore db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) ticket_data = ticket.model_dump(mode='json') ticket_data['created_at'] = datetime.utcnow().isoformat() ticket_data['governance']['approval_status'] = 'PENDING' doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id) await doc_ref.set(ticket_data) # --- Create Initial Audit Event --- audit_event = AuditEvent( ticket_id=ticket.ticket_id, event_type="PROPOSAL_CREATED", actor="opax-mcp-server", # Use a fixed, trusted server identity details={ "severity": ticket.severity, "execution_tool": ticket.proposed_action.execution_tool, "parameter_hash": ticket.proposed_action.parameter_hash, "authorized_approver": ticket.governance.authorized_approver, "timeout_minutes": ticket.governance.timeout_minutes, } ) await _create_audit_event(db, audit_event) # The approval URL now contains the raw token for one-time use. approval_ui_url = f"https://opax.vauco.no/a2h2a/review/{ticket.ticket_id}?token={raw_token}" google_chat_webhook = os.getenv('A2H2A_GOOGLE_CHAT_WEBHOOK') if google_chat_webhook and not test_mode: # The raw_token is NOT included in the chat payload, only in the approval_ui_url chat_payload = { "cards": [ { "header": { "title": f"🚨 A2H2A Proposal: {ticket.severity}", "subtitle": "OSVxCC Security (Prototype - No Execution)", "image": {"imageUrl": "https://www.gstatic.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png"} }, "sections": [ { "widgets": [ { "textParagraph": { "text": f"Category: {ticket.category}
" f"Service: {ticket.source.affected_service}
" f"Tool: {ticket.proposed_action.execution_tool}
" f"Summary: {ticket.context.summary}" } }, { "buttons": [ { "textButton": { "text": "REVIEW IN APPROVAL UI", "onClick": {"openLink": {"url": approval_ui_url}} } } ] } ] } ] } ] } import httpx async with httpx.AsyncClient() as client: await client.post(google_chat_webhook, json=chat_payload) response_payload = {"status": "success", "ticket_id": ticket.ticket_id, "parameter_hash": ticket.proposed_action.parameter_hash} if test_mode: response_payload['raw_token'] = raw_token logger.info(f"A2H2A ticket created successfully: {ticket.ticket_id}", extra={"ticket_id": ticket.ticket_id, "event_type": "TICKET_CREATED"}) A2H2A_METRICS["tickets_created"] += 1 return response_payload except Exception as e: logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}", extra={"ticket_id": ticket.ticket_id if 'ticket' in locals() else 'N/A', "event_type": "TICKET_CREATION_FAILED"}, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --- A2H2A Approval UI (Prototype) --- # These endpoints implement server-side validation of a single-use token # and IAP-verified identity. They only change ticket state; no tool execution occurs. @app.get("/a2h2a/review/{ticket_id}", response_class=HTMLResponse) async def review_a2h2a_ticket(ticket_id: str, token: str, request: Request): """ Displays a review page for an A2H2A ticket. This endpoint is expected to be protected by IAP. """ from google.cloud import firestore db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) ticket_ref = db.collection('a2h2a_tickets').document(ticket_id) ticket_doc = await ticket_ref.get() if not ticket_doc.exists: logger.warning("A2H2A review attempted for non-existent ticket.", extra={"ticket_id": ticket_id, "event_type": "REVIEW_TICKET_NOT_FOUND"}) return HTMLResponse(content="

404: Ticket not found

", status_code=404) ticket = A2H2ATicket(**ticket_doc.to_dict()) # --- Security Validation --- if ticket.governance.approval_status != 'PENDING': logger.warning(f"A2H2A review attempted for already actioned ticket {ticket_id}.", extra={"ticket_id": ticket_id, "status": ticket.governance.approval_status, "event_type": "REVIEW_TICKET_NOT_PENDING"}) return HTMLResponse(content=f"

400: Ticket Not Pending

This ticket has already been actioned. Its current status is: {ticket.governance.approval_status}.

", status_code=400) if ticket.is_expired(): logger.warning(f"A2H2A review attempted for expired ticket {ticket_id}.", extra={"ticket_id": ticket_id, "event_type": "REVIEW_TICKET_EXPIRED"}) A2H2A_METRICS["tickets_expired"] += 1 return HTMLResponse(content="

400: Ticket Expired

", status_code=400) provided_token_hash = hashlib.sha256(token.encode()).hexdigest() if not secrets.compare_digest(provided_token_hash, ticket.governance.approval_token_hash): logger.error(f"Invalid approval token for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "event_type": "REVIEW_INVALID_TOKEN"}) return HTMLResponse(content="

403: Invalid Token

", status_code=403) user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") if not user_email or user_email != ticket.governance.authorized_approver: logger.error(f"Unauthorized approver for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "authorized_approver": ticket.governance.authorized_approver, "event_type": "REVIEW_UNAUTHORIZED_APPROVER"}) return HTMLResponse(content=f"

403: Unauthorized

You ({user_email}) are not the authorized approver ({ticket.governance.authorized_approver}) for this ticket.

", status_code=403) expiry_time = ticket.timestamp + timedelta(minutes=ticket.governance.timeout_minutes) # --- Generate and store CSRF token --- csrf_token = secrets.token_hex(16) csrf_token_hash = hashlib.sha256(csrf_token.encode()).hexdigest() await ticket_ref.update({"governance.csrf_token_hash": csrf_token_hash}) # --- Render HTML Page --- html_content = f""" A2H2A Ticket Review

A2H2A Ticket Review: {ticket.severity}

Authorized Approver: {ticket.governance.authorized_approver}

Expires At: {expiry_time.isoformat()}Z


Ticket ID: {ticket.ticket_id}

Tool: {ticket.proposed_action.execution_tool}

Summary: {ticket.context.summary}

Parameters Hash: {ticket.proposed_action.parameter_hash}


""" return HTMLResponse(content=html_content) async def _process_approval_action(ticket_id: str, token: str, user_email: str, action: str, csrf_token: str): """Helper to handle the logic for approving or rejecting a ticket.""" from google.cloud import firestore db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) ticket_ref = db.collection('a2h2a_tickets').document(ticket_id) ticket_doc = await ticket_ref.get() if not ticket_doc.exists: raise HTTPException(status_code=404, detail="Ticket not found") ticket = A2H2ATicket(**ticket_doc.to_dict()) ticket_dict = ticket_doc.to_dict() # --- Full Security Re-validation --- if ticket.governance.approval_status != 'PENDING': raise HTTPException(status_code=400, detail=f"Ticket has already been actioned (Status: {ticket.governance.approval_status})") if ticket.is_expired(): A2H2A_METRICS["tickets_expired"] += 1 raise HTTPException(status_code=400, detail="Ticket has expired") provided_token_hash = hashlib.sha256(token.encode()).hexdigest() if not secrets.compare_digest(provided_token_hash, ticket.governance.approval_token_hash): raise HTTPException(status_code=403, detail="Invalid token") # --- CSRF Token validation --- stored_csrf_hash = ticket_dict.get("governance", {}).get("csrf_token_hash") if not stored_csrf_hash: logger.error(f"CSRF token missing or already used for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "event_type": "APPROVAL_CSRF_TOKEN_MISSING"}) raise HTTPException(status_code=403, detail="CSRF token missing or already used.") provided_csrf_hash = hashlib.sha256(csrf_token.encode()).hexdigest() if not secrets.compare_digest(provided_csrf_hash, stored_csrf_hash): logger.error(f"Invalid CSRF token for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "event_type": "APPROVAL_INVALID_CSRF_TOKEN"}) raise HTTPException(status_code=403, detail="Invalid CSRF token") if user_email != ticket.governance.authorized_approver: logger.error(f"Unauthorized approver in approval action for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "authorized_approver": ticket.governance.authorized_approver, "event_type": "APPROVAL_UNAUTHORIZED_APPROVER"}) raise HTTPException(status_code=403, detail="Unauthorized") now = datetime.utcnow() update_data = { "governance.approval_status": action, "governance.verified_approver_email": user_email, "governance.token_used_at": now.isoformat(), f"governance.{action.lower()}_at": now.isoformat(), "governance.csrf_token_hash": None # Invalidate CSRF token } await ticket_ref.update(update_data) logger.info(f"A2H2A ticket {ticket_id} has been {action.lower()}.") if action == "APPROVED": A2H2A_METRICS["tickets_approved"] += 1 elif action == "REJECTED": A2H2A_METRICS["tickets_rejected"] += 1 audit_event = AuditEvent( ticket_id=ticket.ticket_id, event_type=f"TICKET_{action}", actor=user_email, details={"message": f"Ticket was {action.lower()} by {user_email}."} ) await _create_audit_event(db, audit_event) return {"status": action.lower(), "ticket_id": ticket_id} @app.post("/a2h2a/approve") async def approve_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...), csrf_token: str = Form(...)): user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") if not user_email: raise HTTPException(status_code=403, detail="IAP header missing or invalid.") return await _process_approval_action(ticket_id, token, user_email, "APPROVED", csrf_token) @app.post("/a2h2a/reject") async def reject_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...), csrf_token: str = Form(...)): user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") if not user_email: raise HTTPException(status_code=403, detail="IAP header missing or invalid.") return await _process_approval_action(ticket_id, token, user_email, "REJECTED", csrf_token) # ── Service discovery ─────────────────────────────────────────────────────── OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app MCP_SECRET = os.environ.get("MCP_SECRET", "") INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "") GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco") GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") WORKSPACE_ADMIN_USER = "chris.christiansen@vauco.no" # Emma-vm Ollama β€” direkte tilkobling OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://34.13.238.133:11434") EMMA_MODEL = os.environ.get("EMMA_MODEL", "gemma3:27b") EMMA_FAST_MODEL = os.environ.get("EMMA_FAST_MODEL", "gemma3:4b") EMMA_LIGHT_MODEL = os.environ.get("EMMA_LIGHT_MODEL", "qwen2.5:3b") logger.info(f"OSVAUCO_AGENT_URL: {OSVAUCO_AGENT_URL}") logger.info(f"OLLAMA_BASE_URL: {OLLAMA_BASE_URL}") # --------------------------------------------------------------------------- # Auth (kun for innkommende kall til opax-mcp, f.eks. fra Gemini TUI) # --------------------------------------------------------------------------- async def _verify_auth(request: Request) -> None: secret = os.getenv("MCP_SECRET") if not secret: logger.error("MCP_SECRET is not configured on the server.") raise HTTPException(status_code=500, detail="MCP_SECRET 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 not secrets.compare_digest(token.encode(), secret.encode()): raise HTTPException(status_code=401, detail="Invalid or missing API key") # --------------------------------------------------------------------------- # Backend Agent helpers (for kall VIDERE til osvauco-agent) # --------------------------------------------------------------------------- def _get_identity_token(audience: str) -> str: """Fetches a Google-signed identity token for the given audience.""" # OPAX_LOCAL_DEV_MODE bypass is for local testing where metadata server is not available. if os.environ.get("OPAX_LOCAL_DEV_MODE") == "true": logger.warning("OPAX_LOCAL_DEV_MODE is enabled, skipping identity token fetch.") return "" try: auth_req = google.auth.transport.requests.Request() token = google.oauth2.id_token.fetch_id_token(auth_req, audience) return token except Exception as e: logger.error(f"Failed to fetch identity token for audience {audience}: {e}", exc_info=True) raise RuntimeError(f"Could not obtain identity token for service-to-service authentication.") from e async def _agent_headers() -> dict: """Headers for machine-to-machine calls to osvauco-agent.""" token = await asyncio.to_thread(_get_identity_token, OSVAUCO_AGENT_URL) headers = { "X-Internal-Key": INTERNAL_API_KEY, "Content-Type": "application/json", } if token: headers["Authorization"] = f"Bearer {token}" return headers 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: # Headers are now generated asynchronously for each request. request_headers = await _agent_headers() r = await c.get(url, headers=request_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: # Headers are now generated asynchronously for each request. request_headers = await _agent_headers() r = await c.post(url, json=body, headers=request_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: """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 = "", history: Optional[List[Dict[str, str]]] = None) -> dict: # ... (beholdt uendret) messages = [] if system: messages.append({"role": "system", "content": system}) if history: messages.extend(history) 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 canonical_emma = CanonicalEmma( chat_function=_ollama_chat, model="gemma3:4b" ) 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() # --------------------------------------------------------------------------- # Phase 2C: Local-only, identity-gated helpers for call_emma # --------------------------------------------------------------------------- def is_call_emma_local_mode_enabled() -> bool: """Checks if the server is configured to run call_emma in local test mode.""" return ( os.getenv("ENABLE_CALL_EMMA_TOOL", "").lower() == "true" and bool(os.getenv("FIRESTORE_EMULATOR_HOST")) ) def get_emma_store() -> MemoryStore: """Factory to get the appropriate memory store for call_emma.""" if not is_call_emma_local_mode_enabled(): raise RuntimeError( "call_emma is enabled only for local Firestore emulator mode." ) project_id = os.getenv("GOOGLE_CLOUD_PROJECT", "opax-phase2c-local") return FirestoreMemoryStore(project_id=project_id) def get_call_emma_caller_context() -> CallerContext: """ Gets the caller context for call_emma, using a server-injected test identity. This is for local/test environments only. """ if not is_call_emma_local_mode_enabled(): raise RuntimeError( "Test caller context is available only in local emulator mode." ) test_caller_id = os.getenv("OPAX_EMMA_TEST_CALLER_ID") if not test_caller_id: raise ValueError( "Configuration error: OPAX_EMMA_TEST_CALLER_ID must be set " "for local emulator tests." ) return derive_caller_context(test_caller_id) async def _local_deterministic_emma_chat(model: str, prompt: str, system: str = "") -> dict: """A local, deterministic, non-network chat function for testing.""" return { "model": "local-test-model", "response": "This is a deterministic local reply.", "done": True, "total_duration_ms": 1, } # --------------------------------------------------------------------------- # 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) # --- 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")}) def _normalize_emma_history(value): if not isinstance(value, list): return [] normalized = [] for entry in value: if not isinstance(entry, dict): continue role = entry.get("role") content = entry.get("content") if role not in {"user", "assistant"}: continue if not isinstance(content, str): continue content = content.strip() if not content: continue normalized.append({"role": role, "content": content}) return normalized[-40:] async def run_emma(p: dict) -> dict: """Kaller den kanoniske Emma-agenten med en prompt.""" capability_system_context = build_capability_system_context() return await canonical_emma.run( prompt=p.get("prompt", p.get("message", "")), history=_normalize_emma_history(p.get("history")), system_context=capability_system_context, ) 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_gitea_repositories(p: dict) -> dict: """Wrapper for the read-only, metadata-only repository catalog.""" if p: raise ValueError("list_gitea_repositories does not accept any arguments.") return await list_allowed_namespace_repositories() 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: dict) -> dict: """Wrapper for the hardened get_file_content handler.""" # Note: server_repo_id (GITEA_REPO) is validated inside the handler now. return await handle_get_file_content(p, GITEA_REPO) async def list_open_issues(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/issues?state=open&limit=20") async def list_repo_files(p: dict) -> dict: """Wrapper for handle_list_repo_files that enforces server-side constraints.""" if set(p.keys()) - {"path"}: raise ValueError("Unsupported list_repo_files input field.") return await handle_list_repo_files({"path": p.get("path")}, GITEA_REPO) 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) # --------------------------------------------------------------------------- # Memory Bank & Deployment Tools # --------------------------------------------------------------------------- PROJECT_DIR = os.path.abspath("project") PROD_BUILD_TRIGGER_ID = "cf42cbc7-a5e8-4724-a569-dbaac1b46a0b" def _resolve_memory_path(file_name: str) -> str: """Resolves a file path and ensures it is within the project/ directory.""" if not file_name or '..' in file_name or file_name.startswith('/'): raise ValueError("Invalid file name.") abs_path = os.path.abspath(os.path.join(PROJECT_DIR, file_name)) if not abs_path.startswith(PROJECT_DIR): raise ValueError("Security violation: Path is outside the allowed memory bank directory.") return abs_path async def read_memory_bank(p: dict) -> dict: """Reads content from a file in the memory bank (project/ directory).""" file_name = p.get("file_name") if not file_name: raise ValueError("Missing required parameter: 'file_name'") file_path = _resolve_memory_path(file_name) try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() return {"file_path": file_name, "content": content} except FileNotFoundError: raise ValueError(f"File not found: {file_name}") except Exception as e: logger.error(f"Error reading memory bank file {file_name}: {e}", exc_info=True) raise async def write_memory_bank(p: dict) -> dict: """Writes or overwrites content to a file in the memory bank (project/ directory).""" file_name = p.get("file_name") content = p.get("content") if not file_name or content is None: raise ValueError("Missing required parameters: 'file_name' and 'content'") file_path = _resolve_memory_path(file_name) try: os.makedirs(PROJECT_DIR, exist_ok=True) with open(file_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"Successfully wrote to memory bank file: {file_name}") return {"status": "success", "file_path": file_name} except Exception as e: logger.error(f"Error writing to memory bank file {file_name}: {e}", exc_info=True) raise async def build_and_deploy_service(p: dict) -> dict: """ Triggers the production build and deployment pipeline for the main service. This action requires manual approval in the Google Cloud Console. """ branch_to_deploy = p.get("branch", "main") logger.info(f"Triggering production deployment for branch: {branch_to_deploy}") result = await trigger_build({ "trigger_id": PROD_BUILD_TRIGGER_ID, "branch": branch_to_deploy, "substitutions": p.get("substitutions", {}) }) return result async def handle_call_emma( arguments: Dict[str, Any], caller: CallerContext, store: MemoryStore, ) -> CallEmmaResponse: """ Handles the call_emma tool, managing conversation state, memory, and auditing for a single turn of conversation with the canonical Emma agent. """ correlation_id = str(uuid.uuid4()) conversation_id = None status = "failure" error_type = None model_name = None history_message_count = 0 memory_count = 0 proposed_action_count = 0 try: request = CallEmmaRequest(**arguments) conversation_id = request.conversation_id if conversation_id: conversation = await store.get_conversation(conversation_id, caller.owner_id) if not conversation: raise ValueError(f"Conversation {conversation_id} not found or access denied.") else: conversation = await store.create_conversation( owner_id=caller.owner_id, workspace_id=caller.workspace_id, created_by=caller.caller_id ) conversation_id = conversation.conversation_id user_message = { "message_id": str(uuid.uuid4()), "conversation_id": conversation_id, "owner_id": caller.owner_id, "workspace_id": caller.workspace_id, "role": "user", "content": request.prompt, "created_at": datetime.now(timezone.utc), "caller_id": caller.caller_id, "correlation_id": correlation_id, } await store.append_message(conversation_id, user_message) history = await store.list_messages(conversation_id, limit=20) history_message_count = len(history) # Per rule #10, recall_memory is not called as it requires an embedding. recalled_memories = [] memory_count = 0 if is_call_emma_local_mode_enabled(): local_emma = CanonicalEmma( chat_function=_local_deterministic_emma_chat, model="local-test-model" ) emma_for_call = local_emma else: emma_for_call = canonical_emma emma_response = await emma_for_call.run( prompt=request.prompt, history=history ) model_name = emma_response.get("model") assistant_message_content = emma_response.get("response", "") assistant_message = { "message_id": str(uuid.uuid4()), "conversation_id": conversation_id, "owner_id": caller.owner_id, "workspace_id": caller.workspace_id, "role": "assistant", "content": assistant_message_content, "created_at": datetime.now(timezone.utc), "caller_id": "agent:emma", "correlation_id": correlation_id, "model": model_name, } await store.append_message(conversation_id, assistant_message) status = "success" # Proposed actions are not implemented yet proposed_actions = [] proposed_action_count = len(proposed_actions) return CallEmmaResponse( reply_text=assistant_message_content, model=model_name or "unknown", conversation_id=conversation_id, correlation_id=correlation_id, proposed_actions=proposed_actions, ) except Exception as e: error_type = type(e).__name__ logger.error( "[handle_call_emma ERROR] correlation_id=%s error_type=%s", correlation_id, error_type, ) raise RuntimeError( "An internal error occurred in handle_call_emma. " f"Correlation ID: {correlation_id}" ) from None finally: try: event_type = "TOOL_CALL" if status == "success" else "TOOL_CALL_FAILED" audit_details = { "tool_name": "call_emma", "conversation_id": conversation_id, "correlation_id": correlation_id, "status": status, "model": model_name, "history_message_count": history_message_count, "memory_count": memory_count, "proposed_action_count": proposed_action_count, "error_type": error_type, } audit_event = EmmaAuditEvent( event_type=event_type, caller_id=caller.caller_id, details={k: v for k, v in audit_details.items() if v is not None}, workspace_id=caller.workspace_id, ) await store.record_audit_event(audit_event) except Exception as audit_e: logger.error( "[handle_call_emma AUDIT FAILED] correlation_id=%s audit_error_type=%s", correlation_id, type(audit_e).__name__, ) # --------------------------------------------------------------------------- # 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 (gemma3:4b) β€” primΓ¦r lokal AI", {"type": "object", "properties": {"prompt": {"type": "string"}, "history": {"type": "array", "items": {"type": "object", "properties": {"role": {"type": "string", "enum": ["user", "assistant"]}, "content": {"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)", {}), "run_terminal": (run_terminal, "KjΓΈr terminalkommando pΓ₯ VM", {"type":"object","properties":{"command":{"type":"string"}}}), # Gitea / VCS "list_gitea_repositories": (list_gitea_repositories, "Lists repositories in the approved 'chris' namespace. Listing does not grant read, build, or deploy authority.", {}), "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"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}), "list_repo_files": (list_repo_files, "Lists files and directories in a Gitea repository path.", {"type": "object", "properties": {"path": {"type": "string"}}}), # 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"]}), # Memory Bank "read_memory_bank": (read_memory_bank, "Reads a file from the project memory bank.", {"type": "object", "properties": {"file_name": {"type": "string"}}, "required": ["file_name"]}), "write_memory_bank": (write_memory_bank, "Writes a file to the project memory bank.", {"type": "object", "properties": {"file_name": {"type": "string"}, "content": {"type": "string"}}, "required": ["file_name", "content"]}), "build_and_deploy_service": (build_and_deploy_service, "Triggers the production build & deployment pipeline.", {"type": "object", "properties": {"branch": {"type": "string"}}, "required": []}), } if is_call_emma_local_mode_enabled(): TOOLS["call_emma"] = (handle_call_emma, "Calls the canonical Emma agent with managed persistence (EMULATOR ONLY)", {"type": "object", "properties": {"prompt": {"type": "string"}}, "required": ["prompt"]}) if provision_new_mcp_module: TOOLS["provision_new_mcp_module"] = (provision_new_mcp_module, "Provisions a new MCP module", {"type":"object","properties":{"module_name":{"type":"string"},"tool_name":{"type":"string"},"tool_spec":{"type":"object"}},"required":["module_name","tool_name","tool_spec"]}) # --------------------------------------------------------------------------- # 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.6.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, 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: if tool_name == "call_emma" and is_call_emma_local_mode_enabled(): caller = get_call_emma_caller_context() store = get_emma_store() result = await handler(tool_args, caller, store) else: result = await handler(tool_args) # For call_emma, the result is already the final response object. # For other tools, we wrap it. if tool_name == "call_emma": return JSONResponse(_jsonrpc_ok(req_id, result.model_dump(mode='json'))) else: return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]})) except Exception as e: if tool_name == "call_emma": error_type = type(e).__name__ logger.error( "[MCP HANDLER ERROR] tool=%s error_type=%s", tool_name, error_type, ) return JSONResponse( _jsonrpc_err( req_id, -32000, f"Server error in tool 'call_emma': {error_type}", ) ) 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.6.0", "ollama": OLLAMA_BASE_URL} # BUMPED