YueGuobin 629bb9194f
refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers
- Slim custom_gns3fy.py to connector-only Gns3Connector and rename to
  connector.py; delete the unused Node/Link/Project dataclasses and
  endpoint wrapper methods (~3000 lines)
- Move the MCP node/link handler implementations into
  gns3_client/api_handlers.py as the shared REST client layer consumed
  by both the MCP service and copilot tools; add available_filters
  handler (exposed as link_available_filters MCP tool) and
  build_gns3_ctx() for copilot callers
- Rewrite the tools_v2 node/link tools on top of the handlers: batch
  lifecycle actions now run in parallel, node creation is a single
  POST, project-wide status reads replace per-node GETs
- Port Project.nodes_inventory/links_summary aggregation into
  project_inventory.py (output shape preserved) and rewrite the
  topology reader / project info tools on it, dropping the unused
  stats/snapshots/drawings calls
- Delete the dead mcp/nodes.py and mcp/links.py (NODE_TOOLS/LINK_TOOLS
  had no consumers; __init__ imports handlers from api_handlers)
- Retarget mcp handler tests to patch api_handlers._get_connector and
  replace test_custom_gns3fy.py with inventory contract tests
2026-08-25 09:15:32 +08:00

149 lines
5.4 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot 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.
#
# GNS3-Copilot 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 GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Project inventory aggregation over the raw GNS3 REST listings.
Replaces the aggregation previously living on the ``Project`` dataclass
(``nodes_inventory`` / ``links_summary``). The output shapes are kept
field-for-field: they feed the LLM topology context and the Nornir
inventory, so any change here is consumer-visible.
"""
from typing import Any
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]:
"""
Build an inventory-style dict keyed by node name.
Shape (per node name):
{server, name, node_id, console_port, console_type, type, ports,
status, x, y, tags, netmiko_device_type, default_username,
default_password}
"""
inventory: dict[str, Any] = {}
for n in nodes:
inventory[n.get("name")] = {
"server": server_host,
"name": n.get("name"),
"node_id": n.get("node_id"),
"console_port": n.get("console"),
"console_type": n.get("console_type"),
"type": n.get("node_type"),
"ports": n.get("ports"),
"status": n.get("status"),
"x": n.get("x"),
"y": n.get("y"),
"tags": n.get("tags") if n.get("tags") else [],
"netmiko_device_type": n.get("netmiko_device_type"),
"default_username": n.get("default_username"),
"default_password": n.get("default_password"),
}
return inventory
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.
Shape (per link): {link_id, node_a, port_a, node_b, port_b}.
Links whose endpoints cannot be resolved are skipped, mirroring the
original Project.links_summary behavior.
"""
summary: list[dict[str, str]] = []
for link in links:
if not link.get("nodes"):
continue
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"]
)
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"]
)
)
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"]
)
)
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,
})
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]:
"""
Fetch a project's metadata, nodes and links and return the aggregated
inventory — the equivalent of the old ``Project.get()`` +
``nodes_inventory()`` + ``links_summary()`` sequence (minus the
stats/snapshots/drawings calls no consumer ever read).
"""
conn = _get_connector(gns3_ctx)
base = conn.base_url
project = conn.http_call("get", f"{base}/projects/{project_id}").json()
nodes = conn.http_call("get", f"{base}/projects/{project_id}/nodes").json()
links = conn.http_call("get", f"{base}/projects/{project_id}/links").json()
server_host = urlparse(gns3_ctx["server_url"]).hostname
return {
"project_id": project.get("project_id", project_id),
"name": project.get("name"),
"status": project.get("status"),
"nodes_inventory": build_nodes_inventory(nodes, server_host),
"links_summary": build_links_summary(nodes, links),
}