chore: update opax-mcp/server.py via opax-mcp
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run

This commit is contained in:
chris 2026-07-25 08:17:03 +00:00
parent d053e7c16c
commit ad4636616c

View File

@ -26,10 +26,10 @@ 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
app = FastAPI(title="opax-mcp", version="3.6.0")
# ── Service discovery ───────────────────────────────────────────────────────
OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app
OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "")
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")
@ -38,22 +38,23 @@ 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
# Emma-vm — HTTP-server (port 8765) og Ollama-fallback (port 11434)
EMMA_SERVER_URL = os.environ.get("EMMA_SERVER_URL", "http://34.13.238.133:8765")
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")
EMMA_LIGHT_MODEL = os.environ.get("EMMA_LIGHT_MODEL", "qwen2.5-coder:7b")
logger.info(f"OSVAUCO_AGENT_URL: {OSVAUCO_AGENT_URL}")
logger.info(f"EMMA_SERVER_URL: {EMMA_SERVER_URL}")
logger.info(f"OLLAMA_BASE_URL: {OLLAMA_BASE_URL}")
# ---------------------------------------------------------------------------
# Auth (kun for innkommende kall til opax-mcp, f.eks. fra Gemini TUI)
# Auth
# ---------------------------------------------------------------------------
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
@ -68,33 +69,26 @@ def _verify_auth(request: Request):
# ---------------------------------------------------------------------------
# Backend Agent helpers (for kall VIDERE til osvauco-agent)
# Backend Agent helpers
# ---------------------------------------------------------------------------
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 {
@ -103,7 +97,6 @@ def _agent_headers() -> dict:
}
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())
@ -111,7 +104,6 @@ async def _agent_get(path: str) -> Any:
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())
@ -123,10 +115,6 @@ async def _agent_post(path: str, body: dict) -> Any:
# ---------------------------------------------------------------------------
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"
@ -144,7 +132,6 @@ def _get_workspace_service(api: str, version: str, scopes: list):
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:
@ -153,34 +140,26 @@ async def create_email_alias(p: dict) -> dict:
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'")
@ -189,36 +168,26 @@ async def send_email_as(p: dict) -> dict:
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": []}
@ -227,67 +196,82 @@ async def get_emails(p: dict) -> dict:
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}
return {"status": "error", "message": result.stderr.strip()}
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
# Emma HTTP-server helpers — primær rute via port 8765
# ---------------------------------------------------------------------------
async def _emma_chat(prompt: str, system: str = "", mode: str = "chat") -> dict:
"""Kaller Emma HTTP-serveren (port 8765) med fallback til Ollama."""
payload = {"prompt": prompt, "system": system, "mode": mode}
try:
async with httpx.AsyncClient(timeout=180) as c:
r = await c.post(f"{EMMA_SERVER_URL}/chat", json=payload)
r.raise_for_status()
return r.json()
except Exception as e:
logger.warning(f"[EMMA SERVER] Fallback til Ollama: {type(e).__name__}: {e}")
return await _ollama_chat(EMMA_MODEL, prompt, system)
async def _emma_models() -> list:
"""Henter modeller fra Emma-serveren, med fallback til Ollama."""
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{EMMA_SERVER_URL}/models")
if r.is_success:
return r.json().get("models", [])
except Exception:
pass
return await _ollama_models()
# ---------------------------------------------------------------------------
# Ollama helpers — direkte fallback 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 }
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) }
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
@ -306,21 +290,18 @@ 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()
@ -328,7 +309,7 @@ async def _gitea_put(path: str, body: dict) -> Any:
# ---------------------------------------------------------------------------
# Tool implementations (refaktorert til å bruke _agent_get/_agent_post)
# Tool implementations
# ---------------------------------------------------------------------------
async def get_billing_summary(p): return await _agent_get("/billing/summary")
@ -364,27 +345,37 @@ async def tui_command(p): return await _agent_post("/tui-comman
# --- 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 ---
# --- AI Agents ---
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 run_emma(p):
"""Kaller Emma HTTP-serveren (port 8765) med automatisk fallback til Ollama."""
return await _emma_chat(
prompt=p.get("prompt", p.get("message", "")),
system=p.get("system", "Du er Emma Vauger, en intelligent og hjelpsom AI-assistent for Vauco-plattformen."),
mode=p.get("mode", "chat")
)
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 _emma_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")}
@ -398,7 +389,6 @@ async def push_file(p):
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")
@ -411,36 +401,24 @@ async def describe_service(p: dict) -> dict:
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"
})
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"
})
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")
@ -453,7 +431,6 @@ async def list_service_revisions(p: dict) -> dict:
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")
@ -466,7 +443,6 @@ async def get_cloud_run_logs(p: dict) -> dict:
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(
@ -478,7 +454,6 @@ async def list_builds(p: dict) -> dict:
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:
@ -492,7 +467,6 @@ async def get_build_log(p: dict) -> dict:
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")
@ -500,46 +474,42 @@ async def get_connector_status(p: dict) -> tuple:
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)}
emma_ok = False
try:
async with httpx.AsyncClient(timeout=5) as c:
r = await c.get(f"{EMMA_SERVER_URL}/health")
emma_ok = r.is_success
except Exception:
emma_ok = False
status_text = f"OPAX-MCP: Live\nGitea: {'Online' if gitea_ok else 'Offline'}\nEmma: {'Online (8765)' if emma_ok else 'Offline'}\nTools: {len(TOOLS)} tilgjengelig"
raw_data = {"opax_mcp_status": "live", "gitea_status": "online" if gitea_ok else "offline", "emma_status": "online" if emma_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)
return ({"status": f"Start-kommando sendt for {instance}."}, {"status": f"Start-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."})
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)
return ({"status": f"Stopp-kommando sendt for {instance}."}, {"status": f"Stopp-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."})
# ---------------------------------------------------------------------------
# Tool registry + MCP schema
@ -562,7 +532,7 @@ TOOLS = {
# 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"}}}),
"trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}},"required":["repo","branch","config"]}),
"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"}}}),
@ -574,8 +544,8 @@ TOOLS = {
"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_cloud_run_logs": (get_cloud_run_logs, "Hent logger for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}, "limit":{"type":"string"}},"required":["service","limit"]}),
"list_builds": (list_builds, "List de siste Cloud Builds", {"type":"object","properties":{"limit":{"type":"string"}},"required":["limit"]}),
"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", {}),
@ -605,28 +575,25 @@ def _jsonrpc_err(req_id, code, 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
return JSONResponse(_jsonrpc_ok(req_id, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},"serverInfo": {"name": "opax-mcp", "version": "3.6.0"}}))
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
if annotations:
tool_entry["annotations"] = annotations
tools_list.append(tool_entry)
return JSONResponse(_jsonrpc_ok(req_id, {"tools": tools_list}))
@ -637,8 +604,6 @@ async def mcp_handler(request: Request):
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:
@ -648,11 +613,8 @@ async def mcp_handler(request: Request):
"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)
@ -666,4 +628,4 @@ async def mcp_handler(request: Request):
@app.get("/health")
async def health():
return {"status": "ok", "service": "opax-mcp", "version": "3.5.0", "ollama": OLLAMA_BASE_URL} # BUMPED
return {"status": "ok", "service": "opax-mcp", "version": "3.6.0", "emma_server": EMMA_SERVER_URL, "ollama": OLLAMA_BASE_URL}