diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 9cf569044..aea2d060b 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -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 diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 3ae7924bf..64bb9bacc 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -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: diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 7c46192c4..2bbb6b11f 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -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),