From 18f4576059772a7ae10309c8bb4d6e59b124a158 Mon Sep 17 00:00:00 2001 From: mineracks <134782215+mineracks@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:34:36 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20Exoscale=20MCP=20server=20?= =?UTF-8?q?=E2=80=94=2015=20tools=20for=20Swiss=20cloud=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + README.md | 90 +++++++ __pycache__/server.cpython-314.pyc | Bin 0 -> 22113 bytes requirements.txt | 2 + server.py | 386 +++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 __pycache__/server.cpython-314.pyc create mode 100644 requirements.txt create mode 100644 server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3dd620f --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +EXOSCALE_API_KEY=EXOxxxxx +EXOSCALE_API_SECRET=xxxxx +EXOSCALE_CONFIG=~/.config/exoscale/exoscale.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b33e8c --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# exoscale-mcp + +MCP server for the Exoscale Swiss cloud platform. + +Uses a hybrid approach: +- **GET** operations hit the Exoscale v2 REST API directly with simple header auth. +- **Write** operations (create/delete/start/stop/reboot/rules) shell out to the `exo` CLI, + which handles HMAC-SHA256 request signing correctly. + +## Prerequisites + +1. **Python 3.11+** and pip +2. **`exo` CLI** installed (`brew install exoscale/tap/exo` or https://community.exoscale.com/documentation/tools/exoscale-command-line-interface/) + and logged in (`exo config add`) +3. An Exoscale API key/secret (IAM → API keys in the console) + +## Setup + +```bash +cd /tmp/exoscale-mcp +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `EXOSCALE_API_KEY` | Yes | — | IAM API key (starts with `EXO`) | +| `EXOSCALE_API_SECRET` | Yes | — | IAM API secret | +| `EXOSCALE_CONFIG` | No | `~/.config/exoscale/exoscale.toml` | Path to exo CLI config file | + +Copy `.env.example` to `.env` and fill in your values. + +## Running standalone + +```bash +source .venv/bin/activate +export EXOSCALE_API_KEY=EXO... +export EXOSCALE_API_SECRET=... +python server.py +``` + +## Claude Code MCP config + +Add to `~/.claude/settings.json` (or `settings.local.json`): + +```json +{ + "mcpServers": { + "exoscale": { + "command": "/tmp/exoscale-mcp/.venv/bin/python", + "args": ["/tmp/exoscale-mcp/server.py"], + "env": { + "EXOSCALE_API_KEY": "EXO...", + "EXOSCALE_API_SECRET": "...", + "EXOSCALE_CONFIG": "/Users/you/.config/exoscale/exoscale.toml" + } + } + } +} +``` + +## Available tools + +| Tool | Method | Description | +|---|---|---| +| `list_zones` | CLI | List all Exoscale zones | +| `list_instance_types` | GET | List instance types in a zone | +| `list_templates` | GET | List OS templates in a zone | +| `list_instances` | CLI | List compute instances in a zone | +| `get_instance` | CLI | Get full details for an instance | +| `create_instance` | CLI | Create a new instance | +| `destroy_instance` | CLI | Permanently destroy an instance | +| `start_instance` | CLI | Start a stopped instance | +| `stop_instance` | CLI | Stop a running instance | +| `reboot_instance` | CLI | Reboot an instance | +| `list_ssh_keys` | CLI | List registered SSH keys | +| `create_ssh_key` | CLI | Register a new SSH public key | +| `list_security_groups` | CLI | List security groups in a zone | +| `get_security_group` | CLI | Get security group details and rules | +| `add_security_group_rule` | CLI | Add an ingress/egress firewall rule | + +## Notes + +- `destroy_instance` is irreversible. The CLI `--force` flag skips the confirmation prompt. +- SSH keys are global (not zone-scoped) on Exoscale. +- Security group rules support protocols: `tcp`, `udp`, `icmp`, `icmpv6`, `gre`, `esp`, `ah`, `ipip`. +- The `exo` CLI must be authenticated for the same account as the API key. diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2b455a9293785aff5c98738af99997d72ddd8d3 GIT binary patch literal 22113 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9714bd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.0.0 +httpx>=0.27.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..306d4c4 --- /dev/null +++ b/server.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Exoscale MCP server — hybrid GET/CLI approach.""" + +import json +import os +import subprocess +from typing import Optional + +import httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("exoscale", instructions="Exoscale Swiss cloud API") + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + +API_KEY = os.environ.get("EXOSCALE_API_KEY", "") +API_SECRET = os.environ.get("EXOSCALE_API_SECRET", "") +EXOSCALE_CONFIG = os.environ.get( + "EXOSCALE_CONFIG", os.path.expanduser("~/.config/exoscale/exoscale.toml") +) + + +def _base_url(zone: str) -> str: + return f"https://api-{zone}.exoscale.com/v2" + + +def _get_headers() -> dict: + if not API_KEY or not API_SECRET: + raise RuntimeError("EXOSCALE_API_KEY and EXOSCALE_API_SECRET must be set") + return { + "Exoscale-Api-Key": API_KEY, + "Exoscale-Api-Secret": API_SECRET, + } + + +def _api_get(path: str, zone: str = "ch-gva-2", params: Optional[dict] = None) -> dict: + url = f"{_base_url(zone)}{path}" + resp = httpx.get(url, headers=_get_headers(), params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +# --------------------------------------------------------------------------- +# CLI helpers +# --------------------------------------------------------------------------- + +def _cli(*args: str, check: bool = True) -> subprocess.CompletedProcess: + """Run exo CLI with the configured config file.""" + cmd = ["exo", "--config", EXOSCALE_CONFIG] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError( + f"exo command failed (exit {result.returncode}):\n" + f"cmd: {' '.join(cmd)}\n" + f"stderr: {result.stderr.strip()}\n" + f"stdout: {result.stdout.strip()}" + ) + return result + + +def _cli_json(*args: str) -> object: + """Run exo CLI and parse JSON output.""" + result = _cli(*args, "-O", "json") + if not result.stdout.strip(): + return [] + return json.loads(result.stdout) + + +# --------------------------------------------------------------------------- +# Zones & infrastructure +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_zones() -> str: + """List all available Exoscale zones.""" + data = _cli_json("zone", "list") + lines = [f" • {z['name']}" for z in data] + return "Exoscale zones:\n" + "\n".join(lines) + + +@mcp.tool() +def list_instance_types(zone: str = "ch-gva-2") -> str: + """List available compute instance types in a zone.""" + data = _api_get("/instance-type", zone=zone) + items = data.get("instance-types", []) + groups: dict[str, list] = {} + for t in items: + fam = t.get("family", "other") + groups.setdefault(fam, []).append(t) + + lines = [f"Instance types in {zone}:\n"] + for fam, types in sorted(groups.items()): + lines.append(f" [{fam}]") + for t in types: + mem_gb = t.get("memory", 0) / 1024 / 1024 / 1024 + auth = "" if t.get("authorized", True) else " (not authorized)" + lines.append( + f" {t.get('family', '')}.{t.get('size', t.get('name', '?'))}" + f" — {t.get('cpus', '?')} vCPU, {mem_gb:.0f} GB RAM{auth}" + ) + return "\n".join(lines) + + +@mcp.tool() +def list_templates(zone: str = "ch-gva-2") -> str: + """List available OS templates in a zone.""" + data = _api_get("/template", zone=zone, params={"visibility": "public"}) + items = data.get("templates", []) + groups: dict[str, list] = {} + for t in items: + fam = t.get("family", "other") + groups.setdefault(fam, []).append(t) + + lines = [f"Templates in {zone}:\n"] + for fam, templates in sorted(groups.items()): + lines.append(f" [{fam}]") + for t in templates: + lines.append(f" {t['name']} (id: {t['id'][:8]}...)") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Compute instances +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_instances(zone: str = "ch-gva-2") -> str: + """List compute instances in a zone.""" + data = _cli_json("compute", "instance", "list", "--zone", zone) + if not data: + return f"No instances found in {zone}." + lines = [f"Instances in {zone}:\n"] + for inst in data: + lines.append( + f" {inst['name']:<30} {inst.get('state', '?'):<10} " + f"{inst.get('type', '?'):<20} {inst.get('ip_address', '-')}" + ) + return "\n".join(lines) + + +@mcp.tool() +def get_instance(name: str, zone: str = "ch-gva-2") -> str: + """Get details for a specific compute instance.""" + data = _cli_json("compute", "instance", "show", name, "--zone", zone) + if not data: + return f"Instance '{name}' not found in {zone}." + + d = data if isinstance(data, dict) else data[0] + lines = [ + f"Instance: {d.get('name')}", + f" ID: {d.get('id')}", + f" Zone: {d.get('zone')}", + f" State: {d.get('state')}", + f" Type: {d.get('instance_type')}", + f" Template: {d.get('template')}", + f" Disk: {d.get('disk_size')}", + f" Public IP: {d.get('ip_address', '-')}", + f" IPv6: {d.get('ipv6_address', '-')}", + f" SSH key: {d.get('ssh_key', '-')}", + f" Security grps:{', '.join(d.get('security_groups', [])) or '-'}", + f" Created: {d.get('creation_date', '-')}", + ] + return "\n".join(lines) + + +@mcp.tool() +def create_instance( + name: str, + zone: str, + instance_type: str, + template: str, + disk_size: int = 50, + ssh_key: str = "", + security_group: str = "default", +) -> str: + """Create a new compute instance. + + instance_type format: standard.micro, standard.small, cpu.large, etc. + template: template name (e.g. "Linux Ubuntu 24.04 LTS 64-bit") or ID. + disk_size: in GiB (default 50). + ssh_key: name of registered SSH key to deploy. + security_group: security group name (default: "default"). + """ + args = [ + "compute", "instance", "create", name, + "--zone", zone, + "--instance-type", instance_type, + "--template", template, + "--disk-size", str(disk_size), + "--security-group", security_group, + ] + if ssh_key: + args += ["--ssh-key", ssh_key] + data = _cli_json(*args) + d = data if isinstance(data, dict) else (data[0] if data else {}) + return ( + f"Instance created:\n" + f" Name: {d.get('name', name)}\n" + f" ID: {d.get('id', '?')}\n" + f" Zone: {d.get('zone', zone)}\n" + f" Type: {d.get('instance_type', instance_type)}\n" + f" IP: {d.get('ip_address', 'pending')}\n" + f" State: {d.get('state', '?')}" + ) + + +@mcp.tool() +def destroy_instance(name: str, zone: str = "ch-gva-2") -> str: + """Permanently destroy a compute instance (irreversible).""" + _cli("compute", "instance", "delete", name, "--zone", zone, "--force") + return f"Instance '{name}' in {zone} has been destroyed." + + +@mcp.tool() +def start_instance(name: str, zone: str = "ch-gva-2") -> str: + """Start a stopped compute instance.""" + _cli("compute", "instance", "start", name, "--zone", zone, "--force") + return f"Instance '{name}' in {zone} started." + + +@mcp.tool() +def stop_instance(name: str, zone: str = "ch-gva-2") -> str: + """Stop a running compute instance.""" + _cli("compute", "instance", "stop", name, "--zone", zone, "--force") + return f"Instance '{name}' in {zone} stopped." + + +@mcp.tool() +def reboot_instance(name: str, zone: str = "ch-gva-2") -> str: + """Reboot a compute instance.""" + _cli("compute", "instance", "reboot", name, "--zone", zone, "--force") + return f"Instance '{name}' in {zone} rebooted." + + +# --------------------------------------------------------------------------- +# SSH keys +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_ssh_keys() -> str: + """List registered SSH keys (global, not zone-specific).""" + data = _cli_json("compute", "ssh-key", "list") + if not data: + return "No SSH keys registered." + lines = ["SSH keys:\n"] + for key in data: + lines.append(f" {key['name']:<30} {key.get('fingerprint', '-')}") + return "\n".join(lines) + + +@mcp.tool() +def create_ssh_key(name: str, public_key_path: str) -> str: + """Register a new SSH public key. + + public_key_path: path to the .pub file on the local machine. + """ + expanded = os.path.expanduser(public_key_path) + if not os.path.exists(expanded): + return f"Error: file not found: {expanded}" + data = _cli_json("compute", "ssh-key", "register", name, expanded) + d = data if isinstance(data, dict) else (data[0] if data else {}) + return ( + f"SSH key registered:\n" + f" Name: {d.get('name', name)}\n" + f" Fingerprint: {d.get('fingerprint', '?')}" + ) + + +# --------------------------------------------------------------------------- +# Security groups +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_security_groups(zone: str = "ch-gva-2") -> str: + """List security groups (global, zone param unused but kept for API consistency).""" + data = _cli_json("compute", "security-group", "list") + if not data: + return "No security groups found." + lines = ["Security groups:\n"] + for sg in data: + lines.append(f" {sg.get('id', '?')[:8]}... {sg['name']}") + return "\n".join(lines) + + +@mcp.tool() +def get_security_group(name: str, zone: str = "ch-gva-2") -> str: + """Get details and rules for a security group.""" + data = _cli_json("compute", "security-group", "show", name) + d = data if isinstance(data, dict) else (data[0] if data else {}) + if not d: + return f"Security group '{name}' not found." + + lines = [ + f"Security group: {d.get('name')}", + f" ID: {d.get('id')}", + f" Description: {d.get('description', '-')}", + ] + + ingress = d.get("ingress_rules", []) + if ingress: + lines.append("\n Ingress rules:") + for r in ingress: + port = ( + f"{r['start_port']}-{r['end_port']}" + if r.get("start_port") != r.get("end_port") + else str(r.get("start_port", "")) + ) + lines.append( + f" [{r.get('protocol','?').upper():>5}] " + f"port {port:<12} from {r.get('network', r.get('security_group', '-')):<20}" + f" # {r.get('description', '')}" + ) + else: + lines.append("\n Ingress rules: (none)") + + egress = d.get("egress_rules", []) + if egress: + lines.append("\n Egress rules:") + for r in egress: + port = ( + f"{r['start_port']}-{r['end_port']}" + if r.get("start_port") != r.get("end_port") + else str(r.get("start_port", "")) + ) + lines.append( + f" [{r.get('protocol','?').upper():>5}] " + f"port {port:<12} to {r.get('network', r.get('security_group', '-')):<20}" + f" # {r.get('description', '')}" + ) + else: + lines.append("\n Egress rules: (none — all outbound allowed by default)") + + instances = d.get("instances", []) + if instances: + lines.append(f"\n Attached instances ({len(instances)}):") + for inst in instances: + lines.append(f" {inst.get('name')} {inst.get('public_ip', '-')}") + + return "\n".join(lines) + + +@mcp.tool() +def add_security_group_rule( + group_name: str, + protocol: str, + port: str, + network: str = "0.0.0.0/0", + description: str = "", + zone: str = "ch-gva-2", + flow: str = "ingress", +) -> str: + """Add a firewall rule to a security group. + + protocol: tcp, udp, icmp, etc. + port: single port (e.g. "443") or range (e.g. "8000-8080"). + network: CIDR block (default: 0.0.0.0/0). + flow: ingress or egress (default: ingress). + """ + args = [ + "compute", "security-group", "rule", "add", group_name, + "--protocol", protocol, + "--port", port, + "--network", network, + "--flow", flow, + ] + if description: + args += ["--description", description] + _cli(*args) + return ( + f"Rule added to security group '{group_name}':\n" + f" Flow: {flow}\n" + f" Protocol: {protocol.upper()}\n" + f" Port: {port}\n" + f" Network: {network}\n" + f" Desc: {description or '(none)'}" + ) + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mcp.run()