overgrowth / mcp-server /server.py
Graham Paasch
Support configurable site counts in GNS3 retail builder
5a217f0
#!/usr/bin/env python3
"""
Overgrowth MCP Server - Network automation via GNS3 and SSH
"""
import asyncio
import json
import logging
import os
import subprocess
import sys
from typing import Any, Dict, List, Optional
from mcp.server import Server
from mcp.types import Tool, TextContent
import requests
from netmiko import ConnectHandler
from pathlib import Path
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("overgrowth-mcp")
# Configuration - support environment variable
GNS3_SERVER = os.getenv("GNS3_SERVER", "http://localhost:3080")
GNS3_API_BASE = f"{GNS3_SERVER}/v2"
SSH_KEY_PATH = Path.home() / ".ssh" / "overgrowth_rsa"
BACKUP_DIR = Path(__file__).parent / "backups"
BACKUP_DIR.mkdir(exist_ok=True)
# Initialize MCP server
app = Server("overgrowth-mcp-server")
class GNS3Client:
"""Client for GNS3 API interactions"""
def __init__(self, base_url: str = GNS3_API_BASE):
self.base_url = base_url
def create_project(self, name: str, project_id: Optional[str] = None) -> Optional[Dict]:
"""Create a GNS3 project (idempotent if name already exists)."""
try:
# If project exists, return it
existing = next((p for p in self.get_projects() if p.get("name") == name), None)
if existing:
return existing
payload: Dict[str, Any] = {"name": name}
if project_id:
payload["project_id"] = project_id
response = requests.post(f"{self.base_url}/projects", json=payload)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to create project {name}: {e}")
return None
def get_projects(self) -> List[Dict]:
"""Get all GNS3 projects"""
try:
response = requests.get(f"{self.base_url}/projects")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get projects: {e}")
return []
def get_project(self, project_id: str) -> Optional[Dict]:
"""Get specific project details"""
try:
response = requests.get(f"{self.base_url}/projects/{project_id}")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get project {project_id}: {e}")
return None
def get_nodes(self, project_id: str) -> List[Dict]:
"""Get all nodes in a project"""
try:
response = requests.get(f"{self.base_url}/projects/{project_id}/nodes")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get nodes for project {project_id}: {e}")
return []
def get_links(self, project_id: str) -> List[Dict]:
"""Get all links in a project"""
try:
response = requests.get(f"{self.base_url}/projects/{project_id}/links")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get links for project {project_id}: {e}")
return []
def set_startup_config(self, project_id: str, node_id: str, config_text: str) -> bool:
"""
Set startup config for a node. Applies to platforms that honor startup-config (e.g., IOSvL2/IOSv).
"""
try:
url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}/startup-config"
response = requests.put(url, data=config_text.encode("utf-8"), headers={"Content-Type": "text/plain"})
response.raise_for_status()
return True
except Exception as e:
logger.error(f"Failed to set startup config for node {node_id}: {e}")
return False
def start_node(self, project_id: str, node_id: str) -> bool:
"""Start a node"""
try:
response = requests.post(
f"{self.base_url}/projects/{project_id}/nodes/{node_id}/start"
)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"Failed to start node {node_id}: {e}")
return False
def stop_node(self, project_id: str, node_id: str) -> bool:
"""Stop a node"""
try:
response = requests.post(
f"{self.base_url}/projects/{project_id}/nodes/{node_id}/stop"
)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"Failed to stop node {node_id}: {e}")
return False
class DeviceManager:
"""Manages SSH connections to network devices"""
def __init__(self, ssh_key_path: Path = SSH_KEY_PATH):
self.ssh_key_path = ssh_key_path
def connect(self, host: str, username: str = "admin", port: int = 22) -> Optional[ConnectHandler]:
"""Connect to a device via SSH"""
device_params = {
'device_type': 'cisco_ios', # Default, can be parameterized
'host': host,
'username': username,
'port': port,
'use_keys': True,
'key_file': str(self.ssh_key_path),
'timeout': 30,
}
try:
connection = ConnectHandler(**device_params)
return connection
except Exception as e:
logger.error(f"Failed to connect to {host}: {e}")
return None
def send_command(self, connection: ConnectHandler, command: str) -> str:
"""Send command to device"""
try:
output = connection.send_command(command)
return output
except Exception as e:
logger.error(f"Failed to send command: {e}")
return f"Error: {str(e)}"
def send_config(self, connection: ConnectHandler, config_commands: List[str]) -> str:
"""Send configuration commands to device"""
try:
output = connection.send_config_set(config_commands)
return output
except Exception as e:
logger.error(f"Failed to send config: {e}")
return f"Error: {str(e)}"
# Initialize clients
gns3 = GNS3Client()
device_mgr = DeviceManager()
@app.list_tools()
async def list_tools() -> List[Tool]:
"""List available MCP tools"""
return [
Tool(
name="get_projects",
description="Get list of all GNS3 projects",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_topology",
description="Get topology information for a GNS3 project including nodes and links",
inputSchema={
"type": "object",
"properties": {
"project_name": {
"type": "string",
"description": "Name of the GNS3 project"
}
},
"required": ["project_name"]
}
),
Tool(
name="create_project",
description="Create or return an existing GNS3 project",
inputSchema={
"type": "object",
"properties": {
"project_name": {
"type": "string",
"description": "Name for the project"
},
"project_id": {
"type": "string",
"description": "Optional explicit project_id to use"
}
},
"required": ["project_name"]
}
),
Tool(
name="start_device",
description="Start a network device in GNS3",
inputSchema={
"type": "object",
"properties": {
"project_name": {
"type": "string",
"description": "Name of the GNS3 project"
},
"device_name": {
"type": "string",
"description": "Name of the device to start"
}
},
"required": ["project_name", "device_name"]
}
),
Tool(
name="stop_device",
description="Stop a network device in GNS3",
inputSchema={
"type": "object",
"properties": {
"project_name": {
"type": "string",
"description": "Name of the GNS3 project"
},
"device_name": {
"type": "string",
"description": "Name of the device to stop"
}
},
"required": ["project_name", "device_name"]
}
),
Tool(
name="get_device_config",
description="Get running configuration from a network device via SSH",
inputSchema={
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "IP address or hostname of device"
},
"username": {
"type": "string",
"description": "SSH username (default: admin)",
"default": "admin"
},
"port": {
"type": "integer",
"description": "SSH port (default: 22)",
"default": 22
}
},
"required": ["host"]
}
),
Tool(
name="configure_device",
description="Send configuration commands to a network device via SSH",
inputSchema={
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "IP address or hostname of device"
},
"commands": {
"type": "array",
"items": {"type": "string"},
"description": "List of configuration commands to send"
},
"username": {
"type": "string",
"description": "SSH username (default: admin)",
"default": "admin"
},
"port": {
"type": "integer",
"description": "SSH port (default: 22)",
"default": 22
}
},
"required": ["host", "commands"]
}
),
Tool(
name="backup_device_config",
description="Backup device configuration to a file",
inputSchema={
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Device IP address or hostname"
},
"device_name": {
"type": "string",
"description": "Device name for backup file"
},
"username": {
"type": "string",
"description": "SSH username",
"default": "admin"
}
},
"required": ["host", "device_name"]
}
),
Tool(
name="build_network_from_description",
description="Build a complete GNS3 network topology from a natural language description. Creates the living digital environment automatically.",
inputSchema={
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Natural language description of the network (e.g., 'A coffee shop chain with 3 locations, each with POS and WiFi')"
},
"project_name": {
"type": "string",
"description": "Name for the GNS3 project",
"default": "overgrowth"
},
"site_count": {
"type": "integer",
"description": "Requested number of branch/store sites (excluding HQ)",
"minimum": 1,
"maximum": 10,
"default": 3
},
"auto_configure": {
"type": "boolean",
"description": "Automatically configure devices after creation",
"default": True
}
},
"required": ["description"]
}
),
Tool(
name="seed_node_config",
description="Set startup config for a node in a GNS3 project",
inputSchema={
"type": "object",
"properties": {
"project_name": {"type": "string"},
"device_name": {"type": "string"},
"config": {"type": "string"}
},
"required": ["project_name", "device_name", "config"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: Any) -> List[TextContent]:
"""Handle tool calls"""
if name == "get_projects":
projects = gns3.get_projects()
project_list = [{"name": p["name"], "id": p["project_id"], "status": p.get("status", "unknown")}
for p in projects]
return [TextContent(
type="text",
text=json.dumps(project_list, indent=2)
)]
elif name == "create_project":
project_name = arguments["project_name"]
project_id = arguments.get("project_id")
project = gns3.create_project(project_name, project_id=project_id)
if not project:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": f"Failed to create project {project_name}"
}))]
return [TextContent(type="text", text=json.dumps({
"success": True,
"project": {
"name": project.get("name"),
"id": project.get("project_id"),
"status": project.get("status", "unknown")
}
}))]
elif name == "seed_node_config":
project_name = arguments["project_name"]
device_name = arguments["device_name"]
config = arguments["config"]
projects = gns3.get_projects()
project = next((p for p in projects if p.get("name") == project_name), None)
if not project:
return [TextContent(type="text", text=json.dumps({"success": False, "error": f"Project {project_name} not found"}))]
project_id = project.get("project_id")
nodes = gns3.get_nodes(project_id)
node = next((n for n in nodes if n.get("name") == device_name), None)
if not node:
return [TextContent(type="text", text=json.dumps({"success": False, "error": f"Device {device_name} not found"}))]
success = gns3.set_startup_config(project_id, node.get("node_id"), config)
return [TextContent(type="text", text=json.dumps({
"success": success,
"project_id": project_id,
"project_name": project_name,
"device": device_name
}))]
elif name == "get_topology":
project_name = arguments["project_name"]
# Find project by name
projects = gns3.get_projects()
project = next((p for p in projects if p["name"] == project_name), None)
if not project:
return [TextContent(
type="text",
text=f"Project '{project_name}' not found"
)]
project_id = project["project_id"]
nodes = gns3.get_nodes(project_id)
links = gns3.get_links(project_id)
topology = {
"project": project["name"],
"status": project.get("status", "unknown"),
"nodes": [
{
"name": n["name"],
"node_type": n["node_type"],
"status": n.get("status", "stopped"),
"console": n.get("console"),
"console_type": n.get("console_type"),
"properties": n.get("properties", {})
}
for n in nodes
],
"links": [
{
"nodes": [
{"node": link["nodes"][0].get("label", "unknown"),
"port": link["nodes"][0].get("adapter_number", 0)},
{"node": link["nodes"][1].get("label", "unknown"),
"port": link["nodes"][1].get("adapter_number", 0)}
] if len(link["nodes"]) >= 2 else []
}
for link in links
]
}
return [TextContent(
type="text",
text=json.dumps(topology, indent=2)
)]
elif name == "start_device":
project_name = arguments["project_name"]
device_name = arguments["device_name"]
# Find project and node
projects = gns3.get_projects()
project = next((p for p in projects if p["name"] == project_name), None)
if not project:
return [TextContent(type="text", text=f"Project '{project_name}' not found")]
nodes = gns3.get_nodes(project["project_id"])
node = next((n for n in nodes if n["name"] == device_name), None)
if not node:
return [TextContent(type="text", text=f"Device '{device_name}' not found")]
success = gns3.start_node(project["project_id"], node["node_id"])
return [TextContent(
type="text",
text=f"Device '{device_name}' {'started successfully' if success else 'failed to start'}"
)]
elif name == "stop_device":
project_name = arguments["project_name"]
device_name = arguments["device_name"]
# Find project and node
projects = gns3.get_projects()
project = next((p for p in projects if p["name"] == project_name), None)
if not project:
return [TextContent(type="text", text=f"Project '{project_name}' not found")]
nodes = gns3.get_nodes(project["project_id"])
node = next((n for n in nodes if n["name"] == device_name), None)
if not node:
return [TextContent(type="text", text=f"Device '{device_name}' not found")]
success = gns3.stop_node(project["project_id"], node["node_id"])
return [TextContent(
type="text",
text=f"Device '{device_name}' {'stopped successfully' if success else 'failed to stop'}"
)]
elif name == "get_device_config":
host = arguments["host"]
username = arguments.get("username", "admin")
port = arguments.get("port", 22)
connection = device_mgr.connect(host, username, port)
if not connection:
return [TextContent(type="text", text=f"Failed to connect to {host}")]
config = device_mgr.send_command(connection, "show running-config")
connection.disconnect()
return [TextContent(type="text", text=config)]
elif name == "configure_device":
host = arguments["host"]
commands = arguments["commands"]
username = arguments.get("username", "admin")
port = arguments.get("port", 22)
connection = device_mgr.connect(host, username, port)
if not connection:
return [TextContent(type="text", text=f"Failed to connect to {host}")]
output = device_mgr.send_config(connection, commands)
connection.disconnect()
return [TextContent(type="text", text=output)]
elif name == "backup_device_config":
host = arguments["host"]
device_name = arguments["device_name"]
username = arguments.get("username", "admin")
connection = device_mgr.connect(host, username)
if not connection:
return [TextContent(type="text", text=f"Failed to connect to {host}")]
config = device_mgr.send_command(connection, "show running-config")
connection.disconnect()
# Save to file
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_file = BACKUP_DIR / f"{device_name}-{timestamp}.cfg"
backup_file.write_text(config)
return [TextContent(
type="text",
text=f"Configuration backed up to {backup_file}"
)]
elif name == "build_network_from_description":
description = arguments.get("description", "")
project_name = arguments.get("project_name", "overgrowth")
auto_configure = arguments.get("auto_configure", True)
site_count_raw = arguments.get("site_count", 3)
try:
site_count = int(site_count_raw)
except Exception:
site_count = 3
site_count = max(1, min(10, site_count))
project = gns3.create_project(project_name, project_id=arguments.get("project_id"))
if not project:
return [
TextContent(
type="text",
text=json.dumps(
{
"success": False,
"error": f"Failed to create project {project_name}",
"project_name": project_name,
}
),
)
]
response_data: Dict[str, Any] = {
"success": True,
"description": description,
"project_name": project_name,
"project_id": project.get("project_id") or project.get("id"),
"site_count_requested": site_count,
}
try:
from build_retail_network import build_retail_network
build_summary = build_retail_network(GNS3_API_BASE, project_name, site_count)
response_data.update(build_summary or {})
response_data["success"] = response_data.get("success", True)
except Exception as e:
logger.error(f"build_network_from_description failed: {e}")
response_data["success"] = False
response_data["error"] = str(e)
if auto_configure and response_data.get("success"):
try:
config_script = Path(__file__).parent / "auto_configure_network.py"
if config_script.exists():
config_result = subprocess.run(
[sys.executable, str(config_script)],
capture_output=True,
text=True,
timeout=180,
)
response_data["auto_configure"] = {
"success": config_result.returncode == 0,
"stdout": config_result.stdout,
"stderr": config_result.stderr,
}
else:
response_data["auto_configure"] = {
"success": False,
"error": "auto_configure_network.py not found",
}
except Exception as e:
response_data["auto_configure"] = {"success": False, "error": str(e)}
return [TextContent(type="text", text=json.dumps(response_data, indent=2))]
else:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
"""Run the MCP server"""
from mcp.server.stdio import stdio_server
async with stdio_server() as (read_stream, write_stream):
logger.info("Overgrowth MCP Server starting...")
await app.run(
read_stream,
write_stream,
app.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())