feat: Add batch mode to node_create and link_create (parallel, max 10 workers)

- node_create accepts nodes=[{template_id, x, y, name?}] for batch creation
- link_create accepts links=[{nodes, link_type?, filters?}] for batch creation
- Uses ThreadPoolExecutor for parallel REST API calls
- Max 10 concurrent workers per batch, backward compatible with single mode
This commit is contained in:
YueGuobin 2026-06-13 22:29:27 +08:00
parent 16d3f28cf5
commit 2c1a83114d
No known key found for this signature in database
3 changed files with 98 additions and 19 deletions

View File

@ -445,12 +445,21 @@ async def node_suspend(
@mcp.tool()
async def node_create(
project_id: Annotated[str, Field(description="UUID of the project")],
template_id: Annotated[str, Field(description="UUID of the template to create the node from")],
x: Annotated[int, Field(description="X coordinate on the project canvas")] = 0,
y: Annotated[int, Field(description="Y coordinate on the project canvas")] = 0,
template_id: Annotated[str | None, Field(description="Template UUID (required for single mode)")] = None,
x: Annotated[int, Field(description="X coordinate")] = 0,
y: Annotated[int, Field(description="Y coordinate")] = 0,
compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local",
nodes: Annotated[list | None, Field(description="Batch mode: [{template_id, x?, y?, name?, compute_id?}] — creates multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Create a new node from a template in a project."""
"""Create one or more nodes from templates.
Single mode: provide template_id, x, y (optional compute_id)
Batch mode: provide nodes=[{template_id, x, y, name?, compute_id?}] creates up to 10 in parallel
"""
if nodes is not None:
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
"project_id": project_id, "nodes": nodes,
})
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
"project_id": project_id, "template_id": template_id,
"x": x, "y": y, "compute_id": compute_id,
@ -527,21 +536,20 @@ async def link_get(
@mcp.tool()
async def link_create(
project_id: Annotated[str, Field(description="UUID of the project")],
nodes: Annotated[list, Field(description="List of node connections, e.g. [{\"node_id\": \"...\", \"adapter_number\": 0, \"port_number\": 0}]")],
nodes: Annotated[list | None, Field(description="Single mode: [{node_id, adapter_number, port_number}]")] = None,
link_type: Annotated[str, Field(description="Link type - ethernet or serial")] = "ethernet",
filters: Annotated[dict, Field(description="Optional packet filters (must use array format): frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]")] = None,
filters: Annotated[dict | None, Field(description="Optional packet filters")] = None,
links: Annotated[list | None, Field(description="Batch mode: [{nodes, link_type?, filters?}] — creates multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Create a link between two nodes in a project.
"""Create one or more links between nodes.
Filters must use array format:
- frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767)
- packet_loss: [rate] - Packet loss percentage (rate: 0 to 100)
- delay: [ms, jitter] - Latency and jitter in milliseconds
- corrupt: [rate] - Packet corruption percentage (rate: 0 to 100)
- bpf: [expression] - Berkeley Packet Filter expression
Example: {"filters": {"delay": [100, 10], "packet_loss": [5]}}
Single mode: provide nodes, link_type (optional filters)
Batch mode: provide links=[{nodes, link_type?, filters?}] up to 10 in parallel
"""
if links:
return await asyncio.to_thread(_run_handler_sync, create_link_handler, {
"project_id": project_id, "links": links,
})
params = {"project_id": project_id, "nodes": nodes, "link_type": link_type}
if filters:
params["filters"] = filters

View File

@ -23,11 +23,14 @@ via Gns3Connector (from custom_gns3fy).
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Helper ─────────────────────────────────────────────────────────────────
@ -63,9 +66,42 @@ def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
if links is not None:
if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"}
results = []
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
if not link_data.get("nodes"):
return {"status": "error", "error": "nodes is required for each link"}
try:
body = {"nodes": link_data["nodes"]}
if link_data.get("link_type"):
body["link_type"] = link_data["link_type"]
if link_data.get("filters"):
body["filters"] = link_data["filters"]
if link_data.get("suspend"):
body["suspend"] = link_data["suspend"]
url = f"{conn.base_url}/projects/{project_id}/links"
resp = conn.http_call("post", url, json_data=body).json()
return {"status": "success", "link": resp}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
futures = {pool.submit(_create_one, l): l for l in links}
for future in as_completed(futures):
results.append(future.result())
return results
# Single mode
nodes = params.get("nodes")
if not project_id or not nodes:
return {"error": "project_id and nodes are required"}
if not nodes:
return {"error": "nodes is required"}
conn = _get_connector(gns3_ctx)
data = {"nodes": nodes}
if "link_type" in params:

View File

@ -23,11 +23,14 @@ via Gns3Connector (from custom_gns3fy).
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Constants ──────────────────────────────────────────────────────────────
# Maximum bytes to return from get_node_file (safety net).
@ -109,9 +112,41 @@ def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id, x, y, name?, compute_id?}]
if nodes is not None:
if not isinstance(nodes, list) or not nodes:
return {"error": "nodes must be a non-empty array"}
results = []
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id")
if not tid:
return {"template_id": tid, "status": "error", "error": "template_id is required"}
try:
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
body = {
"x": node_data.get("x", 0),
"y": node_data.get("y", 0),
"compute_id": node_data.get("compute_id", "local"),
}
resp = conn.http_call("post", url, json_data=body).json()
return {"template_id": tid, "status": "success", "node": resp}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
futures = {pool.submit(_create_one, n): n for n in nodes}
for future in as_completed(futures):
results.append(future.result())
return results
# Single mode
template_id = params.get("template_id")
if not project_id or not template_id:
return {"error": "project_id and template_id are required"}
if not template_id:
return {"error": "template_id is required"}
conn = _get_connector(gns3_ctx)
data = {
"x": params.get("x", 0),