refactor: ruff autofixes

This commit is contained in:
Mark Paronyan committed 2026-09-27 15:55:10 +03:00
1 parent 2ac637c662
commit a0450b6bce
455 files changed
+12322 -14762

No files matched your search

@@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
VALID_NODE_FIELDS = {
# NodeBase
"compute_id", "name", "node_type", "node_id",
"console", "console_type", "console_auto_start",
"aux", "aux_type", "properties", "label", "symbol",
"x", "y", "z", "locked",
"port_name_format", "port_segment_size", "first_port_name",
"custom_adapters", "tags",
"compute_id",
"name",
"node_type",
"node_id",
"console",
"console_type",
"console_auto_start",
"aux",
"aux_type",
"properties",
"label",
"symbol",
"x",
"y",
"z",
"locked",
"port_name_format",
"port_segment_size",
"first_port_name",
"custom_adapters",
"tags",
# Node
"template_id", "project_id", "node_directory", "status",
"command_line", "width", "height", "ports", "console_host",
"template_id",
"project_id",
"node_directory",
"status",
"command_line",
"width",
"height",
"ports",
"console_host",
}
VALID_LINK_FIELDS = {
"link_id", "project_id", "link_type", "nodes", "suspend",
"link_style", "filters", "show_filters_icon",
"capturing", "capture_file_name", "capture_file_path",
"capture_compute_id", "wireshark",
"link_id",
"project_id",
"link_type",
"nodes",
"suspend",
"link_style",
"filters",
"show_filters_icon",
"capturing",
"capture_file_name",
"capture_file_path",
"capture_compute_id",
"wireshark",
}
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
@@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
return Gns3Connector(
url=gns3_ctx["server_url"],
@@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
)
def build_gns3_ctx(
jwt_token: str | None = None, url: str | None = None
) -> dict[str, Any] | None:
def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
"""
Build a handler ``gns3_ctx`` for in-process copilot callers.
@@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
]
raise ValueError(
f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
f'or compact ["id",0,0,"id",0,0], got: {nodes}'
)
# ── Node handlers ──────────────────────────────────────────────────────────
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
"""Helper to run a lifecycle action on multiple nodes in parallel."""
def _act(nid):
try:
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, node_ids))
@@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
return {"error": 'fields must be a list, e.g. ["node_id", "name"]'}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
@@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id")
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id", default_tid)
if not tid:
@@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
if any(not node.get("name") for node in nodes):
# The controller assigns default names (R-1, R-2, ...) and console
# ports in request arrival order, and a parallel fan-out makes the
@@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(nid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, node_ids))
node_id = params.get("node_id")
@@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
# the ~200-char JWT previously embedded here (dropped header segment →
# "Missing 'alg' value in header" on the server).
username = gns3_ctx.get("jwt_username")
ticket = access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
) if username else None
ticket = (
access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
)
if username
else None
)
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ticket:
raw_url += f"?token={ticket}"
@@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
total_lines = len(lines)
# Apply offset/limit
selected = lines[offset: offset + limit] if offset < total_lines else []
selected = lines[offset : offset + limit] if offset < total_lines else []
has_more = (offset + limit) < total_lines or truncated
content = "".join(selected)
@@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
result = conn.http_call(
"post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data
).json()
return {"message": f"Node {node_id} duplicated", "node": result}
@@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
# ── Link handlers ──────────────────────────────────────────────────────────
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
if invalid:
return {
@@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
@@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"}
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
raw_nodes = link_data.get("nodes")
if not raw_nodes:
@@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"status": "success", "link": _filter_link_response(resp, fields)}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate
# results with the links they sent regardless of completion order
@@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(lid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, link_ids))
link_id = params.get("link_id")
@@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _rst(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
@@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"link_id": lid, "status": "reset", "link": r}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_rst, link_ids))
link_id = params.get("link_id")
@@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
def _batch_capture(project_id, link_ids, action, data_builder, conn):
"""Helper for batch capture start/stop."""
def _act(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
@@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn):
return {"link_id": lid, "status": "success"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, link_ids))
@@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
dlt = params.get("data_link_type", "DLT_EN10MB")
ws = params.get("wireshark", False)
fname = params.get("capture_file_name")
def _build(lid):
data = {"data_link_type": dlt, "wireshark": ws}
if fname:
data["capture_file_name"] = fname
return {"json_data": data}
return _batch_capture(project_id, link_ids, "start", _build, conn)
link_id = params.get("link_id")
if not link_id:
@@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
results = [_download(lid) for lid in link_ids]
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
return {
"downloads": results,
"count": len(results),
"note": "Files are in pcap format. URLs include a 10-minute ticket.",
}
link_id = params.get("link_id")
if not link_id:
@@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return {
"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
return {
"message": f"Marker '{marker_name}' deleted from link {link_id}",
"link_id": link_id,
"marker_name": marker_name,
}
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if params.get(opt) is not None:
body[opt] = params[opt]
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
return {
"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
@@ -139,17 +139,11 @@ class Gns3Connector:
self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version
if (
self.auth_type == "basic"
and self.user is not None
and self.cred is not None
):
if self.auth_type == "basic" and self.user is not None and self.cred is not None:
self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
def _authenticate_v3(self) -> None:
"""
@@ -161,15 +155,10 @@ class Gns3Connector:
return
if not self.user or not self.cred:
raise ValueError(
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
raise ValueError("Username and password are required for v3 authentication when no JWT token is provided")
# Construct authentication URL (v3 API uses different base URL)
auth_url = (
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication
@@ -177,21 +166,14 @@ class Gns3Connector:
temp_session.headers["Content-Type"] = "application/json"
try:
response = temp_session.post(
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0)
if response.status_code == 200:
auth_result = response.json()
self.access_token = auth_result["access_token"]
# Update session with new token
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
else:
raise HTTPError(
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
@@ -205,9 +187,7 @@ class Gns3Connector:
try:
# Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode(
token, options={"verify_signature": False}
)
decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False})
exp = decoded.get("exp")
if exp is not None:
return time.time() > float(exp)
@@ -237,12 +217,7 @@ class Gns3Connector:
"""
# Handle JWT authentication
if (
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
if self.auth_type == "jwt" and not self.access_token and self.user and self.cred:
self._authenticate_v3()
# Get request function (e.g., session.get, session.post)
@@ -285,15 +260,10 @@ class Gns3Connector:
try:
# Only attempt parsing when Content-Type is JSON
if (
"application/json"
in response.headers.get("Content-Type", "").lower()
):
if "application/json" in response.headers.get("Content-Type", "").lower():
error_json = response.json()
status = error_json.get("status", "Unknown Status")
message = error_json.get(
"message", "No message provided in JSON."
)
message = error_json.get("message", "No message provided in JSON.")
# Construct a more descriptive new error
new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)",
@@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]:
controller = Controller.instance()
local_compute = controller.get_compute("local")
url = (
f"{local_compute.protocol}://{local_compute.host}:"
f"{local_compute.port}"
)
url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
logger.debug(
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
url,
@@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]:
logger.debug("Local compute not found in Controller: %s", str(e))
return None
except Exception as e:
logger.warning(
"Unexpected error getting URL from Controller: %s", str(e)
)
logger.warning("Unexpected error getting URL from Controller: %s", str(e))
return None
@@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]:
from gns3server.config import Config
server_config = Config.instance().settings.Server
url = (
f"{server_config.protocol.value}://{server_config.host}:"
f"{server_config.port}"
)
url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
logger.debug(
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
url,
@@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]:
return None
def get_gns3_connector(
jwt_token: Optional[str] = None, url: Optional[str] = None
) -> Optional[Gns3Connector]:
def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]:
"""Create and return a Gns3Connector instance with JWT authentication.
URL Resolution Strategy (in order):
@@ -223,9 +213,7 @@ def get_gns3_connector(
return connector
except Exception as e:
logger.error(
"Failed to create Gns3Connector: %s", str(e), exc_info=True
)
logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True)
return None
@@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config(
url = _detect_url_for_api()
# Step 3: Get LLM config
llm_config = get_llm_config(
user_id=user_id, jwt_token=jwt_token, app=app
)
llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app)
if not llm_config:
logger.warning(f"No LLM config found for user {user_id}")
@@ -303,9 +289,7 @@ async def get_gns3_connector_with_llm_config(
return {"connector": connector, "llm_config": llm_config}
except Exception as e:
logger.error(
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
)
logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True)
return None
@@ -375,9 +359,7 @@ def get_gns3_server_host() -> str:
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
return host
except Exception as e:
logger.warning(
"Failed to extract host from URL %s: %s, using fallback", url, e
)
logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
@@ -428,9 +410,7 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
# We're in an async context with a running loop
# This shouldn't happen since this is a sync function
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, get_user_llm_config_with_app(user_id, app)
)
future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app))
return future.result(timeout=10)
except RuntimeError:
# No running event loop - we're in a sync context
@@ -449,26 +429,19 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
return future.result(timeout=10)
else:
# Loop exists but not running - use it
return loop.run_until_complete(
get_user_llm_config_with_app(user_id, app)
)
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
except RuntimeError:
# No event loop exists - create a new one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
get_user_llm_config_with_app(user_id, app)
)
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
finally:
loop.close()
# Fallback: No app provided, try API call (will mask group config API
# keys)
logger.warning(
"No app provided for get_llm_config, group config API keys may be "
"masked"
)
logger.warning("No app provided for get_llm_config, group config API keys may be masked")
return None
except Exception as e:
@@ -62,12 +62,8 @@ logger = logging.getLogger(__name__)
# Context variables for request-scoped data
# Automatically cleaned up when request context ends
_jwt_token_context: ContextVar[Optional[str]] = ContextVar(
"_jwt_token_context", default=None
)
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
"_llm_config_context", default=None
)
_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None)
_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None)
def set_current_jwt_token(token: str) -> None:
@@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool):
"""
# Log received input
logger.info(
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
try:
# Validate project_id parameter
if not project_id:
logger.error("project_id parameter is required.")
return {
"error": (
"project_id parameter is required. Please provide a valid "
"project UUID."
)
}
return {"error": ("project_id parameter is required. Please provide a valid project UUID.")}
# Build handler context (JWT + server URL from request context)
logger.debug("Connecting to GNS3 server...")
@@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": (
"Failed to connect to GNS3 server. Please check your "
"configuration."
)
}
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
# Use the provided project_id directly
logger.info(
f"Retrieving project info for project_id: {project_id}"
)
logger.info(f"Retrieving project info for project_id: {project_id}")
inventory = fetch_project_inventory(gns3_ctx, project_id)
# Get node and link counts
@@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool):
"""
# Log received input
logger.info(
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
try:
# Validate project_id parameter
if not project_id:
logger.error("project_id parameter is required.")
return {
"error": "project_id parameter is required. "
"Please provide a valid project UUID."
}
return {"error": "project_id parameter is required. Please provide a valid project UUID."}
# Build handler context (JWT + server URL)
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
@@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. Please check "
"your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Use the provided project_id directly
logger.info(f"Retrieving topology for project_id: {project_id}")
@@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool):
"project_id": inventory["project_id"],
"name": inventory["name"],
"status": inventory["status"],
"nodes": self._clean_nodes_ports(
copy.deepcopy(inventory["nodes_inventory"])
),
"nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])),
"links": inventory["links_summary"],
}
# Log topology result
logger.info(
"Topology retrieved: project_id=%s, name=%s, nodes=%d, "
"links=%d",
"Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d",
topology.get("project_id"),
topology.get("name"),
len(topology.get("nodes", {})),
@@ -167,10 +156,7 @@ class GNS3TopologyTool(BaseTool):
"""
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
if "ports" in node and isinstance(node["ports"], list):
node["ports"] = [
{"name": port["name"], "short_name": port["short_name"]}
for port in node["ports"]
]
node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]]
return data
@@ -38,9 +38,7 @@ from urllib.parse import urlparse
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
def build_nodes_inventory(
nodes: list[dict[str, Any]], server_host: str | None
) -> dict[str, Any]:
def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]:
"""
Build an inventory-style dict keyed by node name.
@@ -70,9 +68,7 @@ def build_nodes_inventory(
return inventory
def build_links_summary(
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
) -> list[dict[str, str]]:
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
"""
Build a human/LLM-friendly link list resolving node and port names.
@@ -87,46 +83,40 @@ def build_links_summary(
side_a = link["nodes"][0]
side_b = link["nodes"][1]
try:
node_a = next(
x for x in nodes if x.get("node_id") == side_a["node_id"]
)
node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"])
port_a = str(
next(
p["name"]
for p in (node_a.get("ports") or [])
if p["port_number"] == side_a["port_number"]
and p["adapter_number"] == side_a["adapter_number"]
if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"]
)
)
node_b = next(
x for x in nodes if x.get("node_id") == side_b["node_id"]
)
node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"])
port_b = str(
next(
p["name"]
for p in (node_b.get("ports") or [])
if p["port_number"] == side_b["port_number"]
and p["adapter_number"] == side_b["adapter_number"]
if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"]
)
)
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
summary.append({
"link_id": link.get("link_id"),
"node_a": name_a,
"port_a": port_a,
"node_b": name_b,
"port_b": port_b,
})
summary.append(
{
"link_id": link.get("link_id"),
"node_a": name_a,
"port_a": port_a,
"node_b": name_b,
"port_b": port_b,
}
)
except (StopIteration, KeyError, AttributeError):
# Prevent errors when lookups can't match data
continue
return summary
def fetch_project_inventory(
gns3_ctx: dict[str, Any], project_id: str
) -> dict[str, Any]:
def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]:
"""
Fetch a project's metadata, nodes and links and return the aggregated
inventory — the equivalent of the old ``Project.get()`` +