From df25e037ea627bdf86e311ca607c2a71cf65132e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 26 Aug 2026 00:44:57 +0800 Subject: [PATCH] fix: accept the 'local' compute id in compute tools The compute_get/compute_images MCP tools typed compute_id as a UUID, so passing 'local' (the actual id of the built-in compute, which the compute_images description itself pointed to) was rejected by schema validation. Both tools now take a string defaulting to 'local', and the compute_get REST route resolves 'local' through the controller since the local compute has no database entry. --- gns3server/agent/mcp/__init__.py | 17 +++++---- gns3server/agent/mcp/computes.py | 8 ++--- gns3server/services/computes.py | 6 +++- tests/agent/mcp/test_handlers.py | 38 ++++++++++++++++++++ tests/api/routes/controller/test_computes.py | 10 ++++++ 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 3b28b3cea..a74031f5d 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -31,7 +31,6 @@ import json import asyncio import logging import socket -import uuid from uuid import UUID import bcrypt from typing import Any, Annotated @@ -772,12 +771,12 @@ async def compute_list() -> list[dict[str, Any]]: @mcp.tool() async def compute_get( - compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", ) -> list[dict[str, Any]]: - """Get detailed information about a registered remote compute node. + """Get detailed information about a compute node. - NOTE: Only works for computes registered in the database (returned by compute_list). - For the built-in local compute info, use server_statistics instead. + Accepts 'local' for the built-in local compute or a UUID from compute_list + for a registered remote compute. """ return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) @@ -785,12 +784,12 @@ async def compute_get( @mcp.tool() async def compute_images( emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], - compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", ) -> list[dict[str, Any]]: - """List available images for an emulator on a registered compute node. + """List available images for an emulator on a compute node. - NOTE: Only works for computes registered in the database. - For the local compute, the default compute_id is typically found via server_statistics. + Accepts 'local' for the built-in local compute or a UUID from compute_list + for a registered remote compute. """ return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { "emulator": emulator, "compute_id": compute_id, diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index e3abb65bb..254dc53f1 100644 --- a/gns3server/agent/mcp/computes.py +++ b/gns3server/agent/mcp/computes.py @@ -42,20 +42,16 @@ def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: - compute_id = params.get("compute_id") - if not compute_id: - return {"error": "compute_id is required (use compute_list to get the UUID)"} + compute_id = params.get("compute_id") or "local" conn = _get_connector(gns3_ctx) 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]: emulator = params.get("emulator") - compute_id = params.get("compute_id") + compute_id = params.get("compute_id") or "local" if not emulator: return {"error": "emulator is required (e.g. qemu, iou, docker)"} - if not compute_id: - return {"error": "compute_id is required (use compute_list to get the UUID)"} conn = _get_connector(gns3_ctx) images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json() return {"images": images, "count": len(images)} diff --git a/gns3server/services/computes.py b/gns3server/services/computes.py index 995cb7698..a97c8fcf4 100644 --- a/gns3server/services/computes.py +++ b/gns3server/services/computes.py @@ -54,8 +54,12 @@ class ComputesService: self._controller.notification.controller_emit("compute.created", compute.asdict()) return db_compute - async def get_compute(self, compute_id: Union[str, UUID]) -> models.Compute: + async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]: + if str(compute_id) == "local": + # the built-in local compute only lives in the controller, not in the database; + # drop unset fields (e.g. user) as the response schema types them as str + return {k: v for k, v in self._controller.get_compute("local").asdict().items() if v is not None} db_compute = await self._computes_repo.get_compute(compute_id) if not db_compute: raise ControllerNotFoundError(f"Compute '{compute_id}' not found") diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 5b84dae64..b71b33ee0 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -553,6 +553,44 @@ class TestImage: assert result == {"message": "Image installation completed"} +class TestCompute: + + mod = "computes" + + def test_get_local_by_default(self, ctx): + from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"compute_id": "local", "name": "local"}) + m.return_value = conn + result = get_compute_handler({}, ctx) + assert result["compute_id"] == "local" + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/local") + + def test_get_explicit_compute_id(self, ctx): + from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}) + m.return_value = conn + get_compute_handler({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}, ctx) + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6") + + def test_images_local_by_default(self, ctx): + from gns3server.agent.mcp.computes import get_compute_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn(["img1.qcow2"]) + m.return_value = conn + result = get_compute_images_handler({"emulator": "qemu"}, ctx) + assert result["count"] == 1 + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/local/qemu/images") + + def test_images_requires_emulator(self, ctx): + from gns3server.agent.mcp.computes import get_compute_images_handler + assert "error" in get_compute_images_handler({}, ctx) + + # ── Marker (traffic-insight) ──────────────────────────────────────────── diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index c92bc1a14..5b864f02f 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -79,6 +79,16 @@ class TestComputeRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["compute_id"] == str(test_compute.compute_id) + async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None: + + await controller.add_compute( + compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False) + + response = await client.get(app.url_path_for("get_compute", compute_id="local")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["compute_id"] == "local" + assert response.json()["name"] == "local" + async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: params = {