diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 0b8abad61..0709de355 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -418,34 +418,58 @@ async def node_get( @mcp.tool() async def node_start( project_id: Annotated[str, Field(description="UUID of the project")], - node_id: Annotated[str, Field(description="UUID of the node to start")], + node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, + node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — start multiple nodes in parallel")] = None, ) -> list[dict[str, Any]]: - """Start a node in a project.""" - return await asyncio.to_thread(_run_handler_sync, start_node_handler, {"project_id": project_id, "node_id": node_id}) + """Start one or more nodes. Provide node_id for single, or node_ids for batch.""" + params = {"project_id": project_id} + if node_ids: + params["node_ids"] = node_ids + else: + params["node_id"] = node_id + return await asyncio.to_thread(_run_handler_sync, start_node_handler, params) @mcp.tool() async def node_stop( project_id: Annotated[str, Field(description="UUID of the project")], - node_id: Annotated[str, Field(description="UUID of the node to stop")], + node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, + node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — stop multiple nodes in parallel")] = None, ) -> list[dict[str, Any]]: - """Stop a node in a project.""" - return await asyncio.to_thread(_run_handler_sync, stop_node_handler, {"project_id": project_id, "node_id": node_id}) + """Stop one or more nodes. Provide node_id for single, or node_ids for batch.""" + params = {"project_id": project_id} + if node_ids: + params["node_ids"] = node_ids + else: + params["node_id"] = node_id + return await asyncio.to_thread(_run_handler_sync, stop_node_handler, params) @mcp.tool() async def node_reload( project_id: Annotated[str, Field(description="UUID of the project")], - node_id: Annotated[str, Field(description="UUID of the node to reload")], + node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, + node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reload multiple nodes in parallel")] = None, ) -> list[dict[str, Any]]: - """Reload (restart) a node in a project.""" - return await asyncio.to_thread(_run_handler_sync, reload_node_handler, {"project_id": project_id, "node_id": node_id}) + """Reload (restart) one or more nodes. Provide node_id for single, or node_ids for batch.""" + params = {"project_id": project_id} + if node_ids: + params["node_ids"] = node_ids + else: + params["node_id"] = node_id + return await asyncio.to_thread(_run_handler_sync, reload_node_handler, params) @mcp.tool() async def node_suspend( project_id: Annotated[str, Field(description="UUID of the project")], - node_id: Annotated[str, Field(description="UUID of the node to suspend")], + node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, + node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — suspend multiple nodes in parallel")] = None, ) -> list[dict[str, Any]]: - """Suspend a node in a project.""" - return await asyncio.to_thread(_run_handler_sync, suspend_node_handler, {"project_id": project_id, "node_id": node_id}) + """Suspend one or more nodes. Provide node_id for single, or node_ids for batch.""" + params = {"project_id": project_id} + if node_ids: + params["node_ids"] = node_ids + else: + params["node_id"] = node_id + return await asyncio.to_thread(_run_handler_sync, suspend_node_handler, params) @mcp.tool() diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 7ba701d87..e60ec1a76 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -109,11 +109,31 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s return node +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)) + + def start_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"} + node_ids = params.get("node_ids") + if node_ids: + if not isinstance(node_ids, list): + return {"error": "node_ids must be a list"} + conn = _get_connector(gns3_ctx) + return _batch_lifecycle(project_id, node_ids, "start", conn, "started") node_id = params.get("node_id") - if not project_id or not node_id: - return {"error": "project_id and node_id are required"} + if not node_id: + return {"error": "node_id or node_ids is required"} conn = _get_connector(gns3_ctx) conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={}) return {"message": f"Node {node_id} started", "node_id": node_id} @@ -121,9 +141,17 @@ def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict def stop_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"} + node_ids = params.get("node_ids") + if node_ids: + if not isinstance(node_ids, list): + return {"error": "node_ids must be a list"} + conn = _get_connector(gns3_ctx) + return _batch_lifecycle(project_id, node_ids, "stop", conn, "stopped") node_id = params.get("node_id") - if not project_id or not node_id: - return {"error": "project_id and node_id are required"} + if not node_id: + return {"error": "node_id or node_ids is required"} conn = _get_connector(gns3_ctx) conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={}) return {"message": f"Node {node_id} stopped", "node_id": node_id} @@ -131,9 +159,17 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[ def reload_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"} + node_ids = params.get("node_ids") + if node_ids: + if not isinstance(node_ids, list): + return {"error": "node_ids must be a list"} + conn = _get_connector(gns3_ctx) + return _batch_lifecycle(project_id, node_ids, "reload", conn, "reloaded") node_id = params.get("node_id") - if not project_id or not node_id: - return {"error": "project_id and node_id are required"} + if not node_id: + return {"error": "node_id or node_ids is required"} conn = _get_connector(gns3_ctx) conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/reload") return {"message": f"Node {node_id} reloaded", "node_id": node_id} @@ -141,9 +177,17 @@ def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic def suspend_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"} + node_ids = params.get("node_ids") + if node_ids: + if not isinstance(node_ids, list): + return {"error": "node_ids must be a list"} + conn = _get_connector(gns3_ctx) + return _batch_lifecycle(project_id, node_ids, "suspend", conn, "suspended") node_id = params.get("node_id") - if not project_id or not node_id: - return {"error": "project_id and node_id are required"} + if not node_id: + return {"error": "node_id or node_ids is required"} conn = _get_connector(gns3_ctx) conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend") return {"message": f"Node {node_id} suspended", "node_id": node_id}