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.
This commit is contained in:
YueGuobin 2026-08-26 00:44:57 +08:00
parent 6703e50487
commit df25e037ea
No known key found for this signature in database
5 changed files with 63 additions and 16 deletions

View File

@ -31,7 +31,6 @@ import json
import asyncio import asyncio
import logging import logging
import socket import socket
import uuid
from uuid import UUID from uuid import UUID
import bcrypt import bcrypt
from typing import Any, Annotated from typing import Any, Annotated
@ -772,12 +771,12 @@ async def compute_list() -> list[dict[str, Any]]:
@mcp.tool() @mcp.tool()
async def compute_get( 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]]: ) -> 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). Accepts 'local' for the built-in local compute or a UUID from compute_list
For the built-in local compute info, use server_statistics instead. for a registered remote compute.
""" """
return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) 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() @mcp.tool()
async def compute_images( async def compute_images(
emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], 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[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. Accepts 'local' for the built-in local compute or a UUID from compute_list
For the local compute, the default compute_id is typically found via server_statistics. for a registered remote compute.
""" """
return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, {
"emulator": emulator, "compute_id": compute_id, "emulator": emulator, "compute_id": compute_id,

View File

@ -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]: def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
compute_id = params.get("compute_id") compute_id = params.get("compute_id") or "local"
if not compute_id:
return {"error": "compute_id is required (use compute_list to get the UUID)"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json() 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]: def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
emulator = params.get("emulator") emulator = params.get("emulator")
compute_id = params.get("compute_id") compute_id = params.get("compute_id") or "local"
if not emulator: if not emulator:
return {"error": "emulator is required (e.g. qemu, iou, docker)"} 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) conn = _get_connector(gns3_ctx)
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json() images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
return {"images": images, "count": len(images)} return {"images": images, "count": len(images)}

View File

@ -54,8 +54,12 @@ class ComputesService:
self._controller.notification.controller_emit("compute.created", compute.asdict()) self._controller.notification.controller_emit("compute.created", compute.asdict())
return db_compute 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) db_compute = await self._computes_repo.get_compute(compute_id)
if not db_compute: if not db_compute:
raise ControllerNotFoundError(f"Compute '{compute_id}' not found") raise ControllerNotFoundError(f"Compute '{compute_id}' not found")

View File

@ -553,6 +553,44 @@ class TestImage:
assert result == {"message": "Image installation completed"} 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) ──────────────────────────────────────────── # ── Marker (traffic-insight) ────────────────────────────────────────────

View File

@ -79,6 +79,16 @@ class TestComputeRoutes:
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.json()["compute_id"] == str(test_compute.compute_id) 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: async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
params = { params = {