# # Copyright (C) 2026 GNS3 Technologies Inc. # Author: Yue Guobin # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ MCP tool handlers for device configuration via Nornir + Netmiko. These tools connect to network device consoles via telnet/SSH and execute configuration or diagnostic commands. Device connection info is automatically discovered from the project topology using the device's tags for device_type. Prerequisites: - Device must be started (use node_start / node_start_all) - Device must have a 'device_type:' tag set in GNS3 (right-click → Configure → Tags → add 'device_type:cisco_ios_telnet') - Device must have a console port assigned """ import json import logging from typing import Any from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError log = logging.getLogger(__name__) def _render_template(template: str, device_configs: list[dict], commands_field: str = "config_commands") -> list[dict]: """Render a Jinja2 template for each device's vars into the specified commands field. Entries with the same device_name are merged into a single entry so they share one Nornir session and avoid output fragmentation. Each device in device_configs can have: - "vars": dict of template variables (rendered into commands_field) - commands_field: existing commands merged after rendering if present Args: commands_field: field name for the rendered commands, e.g. "config_commands", "commands" """ jinja = JinjaTemplate(template) merged: dict[str, dict] = {} for dev in device_configs: name = dev.get("device_name") if not name: continue vars_data = dev.get("vars", {}) if name not in merged: merged[name] = {"device_name": name, commands_field: list(dev.get(commands_field, []))} entry = merged[name] if vars_data: try: output = jinja.render(**vars_data) lines = [l for l in output.splitlines() if l.strip()] entry[commands_field].extend(lines) except JinjaError as e: error_msg = f"Template rendering failed for '{name}': {e}" log.error(error_msg) return [{"error": error_msg}] return list(merged.values()) # ── Tool handlers ────────────────────────────────────────────────────────── def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: """Send configuration commands to network devices via console.""" project_id = params.get("project_id") device_configs = params.get("device_configs") template = params.get("template") if not project_id or not device_configs: return [{"error": "project_id and device_configs are required"}] if template: device_configs = _render_template(template, device_configs, commands_field="config_commands") if len(device_configs) == 1 and "error" in device_configs[0]: return device_configs from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands tool = ExecuteMultipleDeviceConfigCommands() input_data = json.dumps({ "project_id": project_id, "device_configs": device_configs, }) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], url=gns3_ctx["server_url"], ) def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: """Run read-only diagnostic (show) commands on network devices.""" project_id = params.get("project_id") device_configs = params.get("device_configs") template = params.get("template") if not project_id or not device_configs: return [{"error": "project_id and device_configs (list of {device_name, commands}) are required"}] if template: device_configs = _render_template(template, device_configs, commands_field="commands") if len(device_configs) == 1 and "error" in device_configs[0]: return device_configs from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands tool = ExecuteMultipleDeviceCommands() input_data = json.dumps({ "project_id": project_id, "device_configs": device_configs, }) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], url=gns3_ctx["server_url"], ) def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: """Configure VPCS devices (set IP, gateway, etc.).""" project_id = params.get("project_id") device_configs = params.get("device_configs") if not project_id or not device_configs: return [{"error": "project_id and device_configs are required"}] from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands tool = VPCSCommands() input_data = json.dumps({ "project_id": project_id, "device_configs": device_configs, }) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], url=gns3_ctx["server_url"], )