fix(opax-mcp): install google-cloud-cli in runtime

Several MCP tools call gcloud via subprocess. The container did not include gcloud, causing those tools to fail at runtime. This change installs the google-cloud-cli in the Docker image and has been verified with list_gce_instances and describe_service.
This commit is contained in:
Gemini Agent 2026-07-16 17:16:13 +00:00
parent e2f4339154
commit 7261cf012b
2 changed files with 228 additions and 8 deletions

View File

@ -1,5 +1,11 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl gnupg ca-certificates \
&& echo "deb [signed-by=/usr/share/keyrings/google-cloud.gpg] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list \
&& curl -sS https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/google-cloud.gpg \
&& apt-get update -y && apt-get install -y google-cloud-cli \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .

View File

@ -21,8 +21,8 @@ from datetime import datetime, timezone, timedelta
from typing import Any, Optional
import logging
# Local handlers
from .gitea_handler import handle_get_file_content, handle_list_repo_files
# Absolute import for local handlers
from gitea_handler import handle_get_file_content, handle_list_repo_files
logging.basicConfig(level=logging.INFO)
@ -375,6 +375,150 @@ async def push_file(p):
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
# ---------------------------------------------------------------------------
@ -399,11 +543,23 @@ TOOLS = {
"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"]}),
@ -434,24 +590,82 @@ async def mcp_handler(request: Request):
if method == "initialize":
return JSONResponse(_jsonrpc_ok(req_id, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},"serverInfo": {"name": "opax-mcp", "version": "3.5.0"}})) # BUMPED
if method == "tools/list":
return JSONResponse(_jsonrpc_ok(req_id, {"tools": [
{"name": name, "description": desc, "inputSchema": schema if schema else {"type": "object", "properties": {}}}
for name, (_, desc, schema) in TOOLS.items()
]}))
tools_list = []
for name, tool_tuple in TOOLS.items():
# Støtter både (handler, desc, schema) og (handler, desc, schema, annotations)
if len(tool_tuple) == 4:
_, desc, schema, annotations = tool_tuple
else:
_, desc, schema = tool_tuple
annotations = None
tool_entry = {
"name": name, "description": desc,
"inputSchema": schema if schema else {"type": "object", "properties": {}}
}
if annotations: # Legg kun til feltet hvis det finnes data
tool_entry["annotations"] = annotations
tools_list.append(tool_entry)
return JSONResponse(_jsonrpc_ok(req_id, {"tools": tools_list}))
if method == "tools/call":
tool_name, tool_args = params.get("name") or params.get("tool"), params.get("arguments", params.get("params", {}))
entry = TOOLS.get(tool_name)
if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}"))
handler, _, _ = entry
try:
result = await handler(tool_args)
return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}))
handler_result = await handler(tool_args)
# Håndter både enkle returverdier og (data, meta) tupler
if isinstance(handler_result, tuple) and len(handler_result) == 2:
raw_data, custom_meta = handler_result
else:
raw_data = handler_result
custom_meta = {
"status": "Vellykket.",
"next_action": "Se over data og fortsett.",
"choices": [{"id": "continue", "label": "Fortsett", "style": "primary"}]
}
meta_part = {"meta": custom_meta}
# Bevar original 'content' for bakoverkompatibilitet
content_part = {"content": [{"type": "text", "text": json.dumps(raw_data, ensure_ascii=False)}]}
return JSONResponse(_jsonrpc_ok(req_id, {**meta_part, **content_part}))
except Exception as e:
logger.error(f"[MCP HANDLER ERROR] tool={tool_name} {type(e).__name__}: {e}", exc_info=True)
return JSONResponse(_jsonrpc_err(req_id, -32000, str(e)))
if method.startswith("notifications/"): return JSONResponse(status_code=202, content={})
return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404)
async def start_gce_instance(p: dict) -> tuple:
"""Starts a GCE instance. Assumes us-central1-b if zone is not specified."""
import subprocess
instance = p.get("instance")
zone = p.get("zone", "us-central1-b") # Default zone assumption
if not instance: raise ValueError("Missing required parameter: 'instance'")
result = subprocess.run(["gcloud", "compute", "instances", "start", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Error", "next_action": "Check instance name and permissions."})
raw_data = {"status": f"Start command issued for {instance}."}
custom_meta = {"status": f"Start-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`.", "choices": [{"id": f"list_gce_instances", "label": "Sjekk status nå", "style": "primary"}]}
return (raw_data, custom_meta)
async def stop_gce_instance(p: dict) -> tuple:
"""Stops a GCE instance. Assumes us-central1-b if zone is not specified."""
import subprocess
instance = p.get("instance")
zone = p.get("zone", "us-central1-b") # Default zone assumption
if not instance: raise ValueError("Missing required parameter: 'instance'")
result = subprocess.run(["gcloud", "compute", "instances", "stop", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False)
if result.returncode != 0: return ({"error": result.stderr}, {"status": "Error", "next_action": "Check instance name and permissions."})
raw_data = {"status": f"Stop command issued for {instance}."}
custom_meta = {"status": f"Stopp-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`.", "choices": [{"id": f"list_gce_instances", "label": "Sjekk status nå", "style": "primary"}]}
return (raw_data, custom_meta)
# ---------------------------------------------------------------------------
# Health (keepalive)
# ---------------------------------------------------------------------------