refactor: unify MCP handlers to use http_call directly, relocate node file ops to Node class

- Move node file operations (list_files, delete_file) from Gns3Connector to Node class
- Add link capture/reset operations (reset, start_capture, stop_capture) to Link class
- Convert all MCP handlers to use conn.http_call() directly instead of
  Gns3Connector/Node/Link abstraction methods
- Register 4 new MCP tools: reset_link, start_capture, stop_capture,
  download_capture_file
This commit is contained in:
YueGuobin committed 2026-06-10 13:47:10 +08:00
1 parent 28b06f37c4
commit acce79d243
7 files changed
+387 -119

No files matched your search

+54
View File
@@ -62,6 +62,8 @@ from .nodes import (
from .links import (
get_links_handler, get_link_handler, create_link_handler,
delete_link_handler, update_link_handler,
reset_link_handler, start_capture_handler, stop_capture_handler,
download_capture_file_handler,
)
from .templates import (
list_templates_handler, get_template_handler, create_template_handler,
@@ -658,6 +660,58 @@ async def delete_node_file(
})
# ── Link capture / reset tools ────────────────────────────────────────
@mcp.tool()
async def reset_link(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
"""Reset a link, clearing its state (counters, filters, etc.)."""
return await asyncio.to_thread(_run_handler_sync, reset_link_handler, {
"project_id": project_id, "link_id": link_id,
})
@mcp.tool()
async def start_capture(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB",
capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None,
wireshark: Annotated[bool, Field(description="Open Wireshark automatically (default: false)")] = False,
) -> list[dict[str, Any]]:
"""Start packet capture on a link. The capture file can later be downloaded with download_capture_file."""
return await asyncio.to_thread(_run_handler_sync, start_capture_handler, {
"project_id": project_id, "link_id": link_id,
"data_link_type": data_link_type, "capture_file_name": capture_file_name,
"wireshark": wireshark,
})
@mcp.tool()
async def stop_capture(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
"""Stop packet capture on a link. After stopping, the capture file can be downloaded."""
return await asyncio.to_thread(_run_handler_sync, stop_capture_handler, {
"project_id": project_id, "link_id": link_id,
})
@mcp.tool()
async def download_capture_file(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
"""Get the download URL and instructions for a PCAP capture file. Use curl to download."""
return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, {
"project_id": project_id, "link_id": link_id,
})
# ── Auth‑wrapped SSE app ──────────────────────────────────────────────
def _make_auth_wrapper(inner_app):
+3 -3
View File
@@ -37,14 +37,14 @@ def _get_connector(gns3_ctx: dict[str, Any]):
def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
computes = conn.get_computes()
computes = conn.http_call("get", f"{conn.base_url}/computes").json()
return {"computes": computes, "count": len(computes)}
def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
compute_id = params.get("compute_id", "local")
conn = _get_connector(gns3_ctx)
return conn.get_compute(compute_id=compute_id)
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json()
def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -53,7 +53,7 @@ def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if not emulator:
return {"error": "emulator is required (e.g. qemu, iou, docker)"}
conn = _get_connector(gns3_ctx)
images = conn.get_compute_images(emulator=emulator, compute_id=compute_id)
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
return {"images": images, "count": len(images)}
+117 -2
View File
@@ -48,7 +48,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
links = conn.get_links(project_id=project_id)
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json()
return {"links": links, "count": len(links)}
@@ -58,7 +58,7 @@ def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
return conn.get_link(project_id=project_id, link_id=link_id)
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -105,6 +105,64 @@ def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return conn.http_call("put", url, json_data=update_data).json()
# ── Link capture / reset handlers ──────────────────────────────────────
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
result = conn.http_call("post", url).json()
return {"message": f"Link {link_id} reset", "link": result}
def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
data = {
"data_link_type": params.get("data_link_type", "DLT_EN10MB"),
"wireshark": params.get("wireshark", False),
}
if params.get("capture_file_name"):
data["capture_file_name"] = params["capture_file_name"]
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
result = conn.http_call("post", url, json_data=data).json()
return {"message": f"Capture started on link {link_id}", "link": result}
def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
conn.http_call("post", url)
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
auth_token = gns3_ctx['jwt_token']
return {
"link_id": link_id,
"download_url": download_url,
"curl_command": f"curl -L -o capture.pcap -H 'Authorization: Bearer {auth_token}' '{download_url}'",
"note": "Use the curl command to download the PCAP capture file. "
"The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
}
# ── Tool definitions ───────────────────────────────────────────────────────
LINK_TOOLS = [
@@ -193,4 +251,61 @@ LINK_TOOLS = [
},
"handler": update_link_handler,
},
{
"name": "reset_link",
"description": "Reset a link, clearing its state (counters, filters, etc.)",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": reset_link_handler,
},
{
"name": "start_capture",
"description": "Start packet capture on a link. The capture file can later be downloaded with download_capture_file.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
"data_link_type": {"type": "string", "description": "Data link type (optional, default: DLT_EN10MB)"},
"capture_file_name": {"type": "string", "description": "Capture file name (optional)"},
"wireshark": {"type": "boolean", "description": "Open Wireshark automatically (optional, default: false)"},
},
"required": ["project_id", "link_id"],
},
"handler": start_capture_handler,
},
{
"name": "stop_capture",
"description": "Stop packet capture on a link. After stopping, the capture file can be downloaded.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": stop_capture_handler,
},
{
"name": "download_capture_file",
"description": "Get the download URL and instructions for a PCAP capture file from a link. "
"Use the returned curl command to download the file. "
"The PCAP file can be analyzed with Wireshark or tcpdump.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": download_capture_file_handler,
},
]
+17 -12
View File
@@ -54,7 +54,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
nodes = conn.get_nodes(project_id=project_id)
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json()
return {"nodes": nodes, "count": len(nodes)}
@@ -64,7 +64,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
return conn.get_node(project_id=project_id, node_id=node_id)
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -155,7 +155,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
node = conn.get_node(project_id=project_id, node_id=node_id)
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
console_type = node.get("console_type", "unknown")
ws_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={gns3_ctx['jwt_token']}"
@@ -181,12 +181,13 @@ def list_node_files_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
files = conn.list_node_files(
project_id=project_id,
node_id=node_id,
path=params.get("path", ""),
recursive=params.get("recursive", False),
)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
query = {}
if params.get("path"):
query["path"] = params["path"]
if params.get("recursive"):
query["recursive"] = "true"
files = conn.http_call("get", url, params=query if query else None).json()
return {"files": files, "count": len(files)}
@@ -201,7 +202,8 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
limit = params.get("limit", 200)
conn = _get_connector(gns3_ctx)
raw = conn.get_node_file(project_id=project_id, node_id=node_id, file_path=file_path)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
raw = conn.http_call("get", url).text
total_bytes = len(raw.encode("utf-8"))
truncated = False
@@ -240,7 +242,8 @@ def write_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
if not project_id or not node_id or not file_path or content is None:
return {"error": "project_id, node_id, file_path and content are required"}
conn = _get_connector(gns3_ctx)
conn.write_node_file(project_id=project_id, node_id=node_id, file_path=file_path, content=content)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
@@ -251,7 +254,9 @@ def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -
if not project_id or not node_id or not file_path:
return {"error": "project_id, node_id and file_path are required"}
conn = _get_connector(gns3_ctx)
conn.delete_node_file(project_id=project_id, node_id=node_id, file_path=file_path)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("delete", url)
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
+10 -8
View File
@@ -46,7 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
projects = conn.get_projects()
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
return {"projects": projects, "count": len(projects)}
@@ -55,7 +55,7 @@ def get_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
project = conn.get_project(project_id=project_id)
project = conn.http_call("get", f"{conn.base_url}/projects/{project_id}").json()
if project is None:
return {"error": f"Project '{project_id}' not found"}
return project
@@ -69,7 +69,7 @@ def create_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
project_data = {"name": name}
if "description" in params:
project_data["description"] = params["description"]
return conn.create_project(**project_data)
return conn.http_call("post", f"{conn.base_url}/projects", json_data=project_data).json()
def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -77,7 +77,7 @@ def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
conn.delete_project(project_id=project_id)
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}")
return {"message": f"Project '{project_id}' deleted", "project_id": project_id}
@@ -115,7 +115,7 @@ def update_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
kwargs = {k: v for k, v in params.items() if k != "project_id" and v is not None}
return conn.update_project(project_id=project_id, **kwargs)
return conn.http_call("put", f"{conn.base_url}/projects/{project_id}", json_data=kwargs).json()
def duplicate_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -127,7 +127,7 @@ def duplicate_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return {"error": "name is required"}
conn = _get_connector(gns3_ctx)
kwargs = {k: v for k, v in params.items() if k not in ("project_id",) and v is not None}
return conn.duplicate_project(project_id=project_id, **kwargs)
return conn.http_call("post", f"{conn.base_url}/projects/{project_id}/duplicate", json_data=kwargs).json()
def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -136,7 +136,8 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
try:
content = conn.get_project_file(project_id=project_id, file_path="README.txt")
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
content = conn.http_call("get", url).text
return {"project_id": project_id, "file": "README.txt", "content": content}
except Exception as e:
if "404" in str(e):
@@ -152,7 +153,8 @@ def update_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if content is None:
return {"error": "content is required"}
conn = _get_connector(gns3_ctx)
conn.write_project_file(project_id=project_id, file_path="README.txt", content=content)
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
return {"message": "README.txt updated", "project_id": project_id}
+38 -19
View File
@@ -45,7 +45,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
templates = conn.get_templates()
templates = conn.http_call("get", f"{conn.base_url}/templates").json()
return {"templates": templates, "count": len(templates)}
@@ -57,10 +57,16 @@ def get_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
return {"error": "template_id or name is required"}
conn = _get_connector(gns3_ctx)
template = conn.get_template(name=name, template_id=template_id)
if template is None:
return {"error": "Template not found"}
if template_id:
template = conn.http_call("get", f"{conn.base_url}/templates/{template_id}").json()
else:
# Find template by name
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
matches = [t for t in all_templates if t.get("name") == name]
if not matches:
return {"error": f"Template '{name}' not found"}
template = matches[0]
return template
@@ -72,14 +78,12 @@ def create_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "name and template_type are required"}
conn = _get_connector(gns3_ctx)
# Handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
create_params = params["kwargs"]
else:
create_params = params
return conn.create_template(**create_params)
data = {
"name": name,
"template_type": template_type,
"compute_id": params.get("compute_id", "local"),
}
return conn.http_call("post", f"{conn.base_url}/templates", json_data=data).json()
def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -91,13 +95,20 @@ def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
conn = _get_connector(gns3_ctx)
# Extract update parameters - handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_params = params["kwargs"]
else:
update_params = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")}
# Resolve name to ID if needed
if not template_id and name:
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
matches = [t for t in all_templates if t.get("name") == name]
if not matches:
return {"error": f"Template '{name}' not found"}
template_id = matches[0]["template_id"]
return conn.update_template(name=name, template_id=template_id, **update_params)
update_data = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")}
# Support nested kwargs from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_data = params["kwargs"]
return conn.http_call("put", f"{conn.base_url}/templates/{template_id}", json_data=update_data).json()
def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@@ -108,7 +119,15 @@ def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "template_id or name is required"}
conn = _get_connector(gns3_ctx)
conn.delete_template(name=name, template_id=template_id)
if not template_id and name:
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
matches = [t for t in all_templates if t.get("name") == name]
if not matches:
return {"error": f"Template '{name}' not found"}
template_id = matches[0]["template_id"]
conn.http_call("delete", f"{conn.base_url}/templates/{template_id}")
return {"message": f"Template deleted"}