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:
parent
e2f4339154
commit
7261cf012b
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user