From 16162f20681e5baf99d5c30954ad8e7fa2d47af4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 16:34:29 +0200 Subject: [PATCH 1/5] chore(release): bump to 0.5.0 Bump the package version to 0.5.0 in pyproject.toml and __init__.py as part of the coordinated 0.5.0 release. The ros2-medkit-client dependency pin is unchanged (the 0.5.0 client is published separately). --- pyproject.toml | 2 +- src/ros2_medkit_mcp/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 68652a7..39f5422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ros2-medkit-mcp" -version = "0.1.0" +version = "0.5.0" description = "MCP server adapter for ros2_medkit SOVD HTTP API" authors = ["bburda "] readme = "README.md" diff --git a/src/ros2_medkit_mcp/__init__.py b/src/ros2_medkit_mcp/__init__.py index 1732f72..22e227a 100644 --- a/src/ros2_medkit_mcp/__init__.py +++ b/src/ros2_medkit_mcp/__init__.py @@ -1,3 +1,3 @@ """ros2_medkit_mcp - MCP adapter for ros2_medkit SOVD HTTP API.""" -__version__ = "0.1.0" +__version__ = "0.5.0" From 02a8b37d82bd0bf23b7022dfb1814934062a6fcf Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 11 Jun 2026 15:49:52 +0200 Subject: [PATCH 2/5] chore(release): port to ros2_medkit_client 0.5.0 Pin the generated client to the 0.5.0 release wheel and adapt the SovdClient wrapper to the regenerated typed-DTO surface: - Bump the client wheel URL to py-v0.5.0 and relock. - get_entity: entity type values are now lowercase (area/component/app/function) to match the 0.5.0 list-item type enum. - prepare/execute/automate_update: the 0.5.0 endpoints are body-less PUTs, so stop sending a request body. - upload_script / upload_bulk_data: issue the multipart upload directly with a "file" part (the field both gateway upload handlers require). The generated multipart body model serializes properties as text/plain via str(), which corrupts binary payloads. Update test fixtures for the 0.5.0 response models, which add required fields: href on entity list items, multishot/persistent on triggers, asynchronous_execution/proximity_proof_required on operations, creation_date/mimetype on bulk-data descriptors, and the x-medkit envelope on update status. --- poetry.lock | 10 ++-- pyproject.toml | 2 +- src/ros2_medkit_mcp/client.py | 101 ++++++++++++++++++---------------- tests/test_bulkdata_tools.py | 16 +++++- tests/test_mcp_app.py | 61 ++++++++++++++++++-- tests/test_new_tools.py | 5 ++ tests/test_tools.py | 59 +++++++++++++++++--- 7 files changed, 186 insertions(+), 68 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9798d47..27980f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1244,13 +1244,13 @@ httpx = ">=0.25.0" [[package]] name = "ros2-medkit-client" -version = "0.1.1" +version = "0.5.0" description = "Async Python client for the ros2_medkit gateway" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "ros2_medkit_client-0.1.1-py3-none-any.whl", hash = "sha256:252b0cfed6002b004262efdddd146ff3e52015034509b9083dc046ad53a811a3"}, + {file = "ros2_medkit_client-0.5.0-py3-none-any.whl", hash = "sha256:b8cacefb5bade0e975f7d5f8fbba32209e111942b19a566f0563f38a1f0d0e37"}, ] [package.dependencies] @@ -1263,7 +1263,7 @@ dev = ["pytest (>=8.0)", "pytest-asyncio (>=0.24)", "respx (>=0.22)", "ruff (>=0 [package.source] type = "url" -url = "https://github.com/selfpatch/ros2_medkit_clients/releases/download/py-v0.1.1/ros2_medkit_client-0.1.1-py3-none-any.whl" +url = "https://github.com/selfpatch/ros2_medkit_clients/releases/download/py-v0.5.0/ros2_medkit_client-0.5.0-py3-none-any.whl" [[package]] name = "rpds-py" @@ -1812,4 +1812,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "8713781f94abcaddb1791c3af82df6172df0361eee7afb09b8fe374e3dc6ec1c" +content-hash = "26a82b5b97d647f03b4dbca4029849a294c0dd015f721beb73fd145db98baa75" diff --git a/pyproject.toml b/pyproject.toml index 39f5422..fde6ee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ pydantic = "^2.10.0" uvicorn = { version = "^0.34.0", extras = ["standard"] } starlette = "^0.45.0" # Distributed via GitHub Releases wheel (no PyPI yet). Replace with version constraint when available. -ros2-medkit-client = {url = "https://github.com/selfpatch/ros2_medkit_clients/releases/download/py-v0.1.1/ros2_medkit_client-0.1.1-py3-none-any.whl"} +ros2-medkit-client = {url = "https://github.com/selfpatch/ros2_medkit_clients/releases/download/py-v0.5.0/ros2_medkit_client-0.5.0-py3-none-any.whl"} [tool.poetry.group.dev.dependencies] pytest = "^8.3.0" diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 98bd66c..fe402de 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -557,6 +557,41 @@ async def _raw_request(self, method: str, path: str) -> Any: except httpx.RequestError as e: raise SovdClientError(message=f"Request failed: {e}") from e + async def _raw_upload( + self, + path: str, + filename: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> dict[str, Any]: + """POST a multipart/form-data upload with a ``file`` field. + + The generated upload models serialize each property as a text/plain part + via ``str(value)``, which corrupts binary payloads. The gateway expects a + ``file`` part (see bulk-data/scripts handlers), so issue the multipart + request directly. Path segments must be pre-encoded by the caller. + """ + try: + hc = await self._httpx_client() + files = {"file": (filename, content, content_type)} + response = await hc.post(path, files=files) + if not response.is_success: + raise SovdClientError( + message=f"Gateway returned HTTP {response.status_code}", + status_code=response.status_code, + ) + if response.status_code == 204 or not response.content: + return {} + try: + return response.json() + except ValueError as e: + raise SovdClientError( + message="Failed to decode JSON response from gateway", + status_code=response.status_code, + ) from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + # ==================== Server ==================== async def get_version(self) -> dict[str, Any]: @@ -612,7 +647,7 @@ async def get_entity(self, entity_id: str) -> dict[str, Any]: entities = await self.list_entities() for entity in entities: if entity.get("id") == entity_id: - if entity.get("type") == "Component": + if entity.get("type") == "component": try: component_data = await self.get_component_data(entity_id) return {**entity, "data": component_data} @@ -973,25 +1008,11 @@ async def upload_bulk_data( filename: str, entity_type: str = "apps", ) -> dict[str, Any]: - fn = _entity_func("bulk_data", "upload", entity_type) - # upload expects a File object (binary upload), not a dict body. - import io - - from ros2_medkit_client._generated.types import File - - file_obj = File( - payload=io.BytesIO(file_content), - file_name=filename, - mime_type="application/octet-stream", - ) - return await self._call_void( - fn, - **{ - _entity_id_kwarg(entity_type): entity_id, - "category_id": category, - "body": file_obj, - }, + path = ( + f"/{quote(entity_type, safe='')}/{quote(entity_id, safe='')}" + f"/bulk-data/{quote(category, safe='')}" ) + return await self._raw_upload(path, filename, file_content) # ==================== Logs ==================== @@ -1083,20 +1104,9 @@ async def get_script( async def upload_script( self, entity_id: str, script_content: str, entity_type: str = "components" ) -> dict[str, Any]: - fn = _entity_func("scripts", "upload", entity_type) - # upload_script expects a File object (binary upload), not a dict body. - # Build the File object from the script content string. - import io - - from ros2_medkit_client._generated.types import File - - file_obj = File( - payload=io.BytesIO(script_content.encode("utf-8")), - file_name="script.py", - mime_type="application/octet-stream", - ) - return await self._call_void( - fn, **{_entity_id_kwarg(entity_type): entity_id, "body": file_obj} + path = f"/{quote(entity_type, safe='')}/{quote(entity_id, safe='')}/scripts" + return await self._raw_upload( + path, "script.py", script_content.encode("utf-8"), "text/x-python" ) async def execute_script( @@ -1299,24 +1309,21 @@ async def get_update_status(self, update_id: str) -> dict[str, Any]: return await self._call(updates.get_update_status.asyncio, update_id=update_id) async def prepare_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: - # prepare_update returns 202 Accepted on success. - # The generated client returns None for 202, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - return await self._call_update_action( - updates.prepare_update.asyncio, update_id=update_id, body=config - ) + # 0.5.0: prepare is a body-less PUT returning 202 Accepted. The endpoint + # no longer accepts a request body; config is retained for MCP tool + # compatibility but not transmitted. + del config + return await self._call_update_action(updates.prepare_update.asyncio, update_id=update_id) async def execute_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: - # execute_update returns 202 Accepted on success. - return await self._call_update_action( - updates.execute_update.asyncio, update_id=update_id, body=config - ) + # 0.5.0: execute is a body-less PUT returning 202 Accepted (see prepare_update). + del config + return await self._call_update_action(updates.execute_update.asyncio, update_id=update_id) async def automate_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: - # automate_update returns 202 Accepted on success. - return await self._call_update_action( - updates.automate_update.asyncio, update_id=update_id, body=config - ) + # 0.5.0: automate is a body-less PUT returning 202 Accepted (see prepare_update). + del config + return await self._call_update_action(updates.automate_update.asyncio, update_id=update_id) async def _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: """Call a generated update action function that returns 202 with None body.""" diff --git a/tests/test_bulkdata_tools.py b/tests/test_bulkdata_tools.py index ac4d157..0f37670 100644 --- a/tests/test_bulkdata_tools.py +++ b/tests/test_bulkdata_tools.py @@ -311,8 +311,20 @@ async def test_list_bulk_data_categories(self, client: SovdClient) -> None: async def test_list_bulk_data(self, client: SovdClient) -> None: """Test list_bulk_data method.""" items = [ - {"id": "uuid-1", "name": "File 1", "size": 1024}, - {"id": "uuid-2", "name": "File 2", "size": 2048}, + { + "id": "uuid-1", + "name": "File 1", + "size": 1024, + "mimetype": "application/x-mcap", + "creation_date": "2026-06-11T12:00:00Z", + }, + { + "id": "uuid-2", + "name": "File 2", + "size": 2048, + "mimetype": "application/x-mcap", + "creation_date": "2026-06-11T12:05:00Z", + }, ] respx.get("http://test-sovd:8080/api/v1/apps/motor/bulk-data/rosbags").mock( return_value=httpx.Response(200, json={"items": items}) diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py index 3d67e00..1eb95cd 100644 --- a/tests/test_mcp_app.py +++ b/tests/test_mcp_app.py @@ -117,13 +117,32 @@ async def test_entities_list_call(self, client: SovdClient) -> None: """Test entities_list tool integration.""" respx.get("http://test-sovd:8080/api/v1/areas").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "powertrain", "name": "powertrain", "type": "Area"}]} + 200, + json={ + "items": [ + { + "id": "powertrain", + "name": "powertrain", + "type": "area", + "href": "/areas/powertrain", + } + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/components").mock( return_value=httpx.Response( 200, - json={"items": [{"id": "temp_sensor", "name": "temp_sensor", "type": "Component"}]}, + json={ + "items": [ + { + "id": "temp_sensor", + "name": "temp_sensor", + "type": "component", + "href": "/components/temp_sensor", + } + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/apps").mock( @@ -147,7 +166,17 @@ async def test_entities_list_with_filter(self, client: SovdClient) -> None: """Test entities_list tool with filter.""" respx.get("http://test-sovd:8080/api/v1/areas").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "powertrain", "name": "powertrain", "type": "Area"}]} + 200, + json={ + "items": [ + { + "id": "powertrain", + "name": "powertrain", + "type": "area", + "href": "/areas/powertrain", + } + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/components").mock( @@ -155,8 +184,18 @@ async def test_entities_list_with_filter(self, client: SovdClient) -> None: 200, json={ "items": [ - {"id": "temp_sensor", "name": "temp_sensor", "type": "Component"}, - {"id": "rpm_sensor", "name": "rpm_sensor", "type": "Component"}, + { + "id": "temp_sensor", + "name": "temp_sensor", + "type": "component", + "href": "/components/temp_sensor", + }, + { + "id": "rpm_sensor", + "name": "rpm_sensor", + "type": "component", + "href": "/components/rpm_sensor", + }, ] }, ) @@ -199,7 +238,17 @@ async def test_list_operations_call(self, client: SovdClient) -> None: """Test list_operations tool integration.""" respx.get("http://test-sovd:8080/api/v1/components/test-comp/operations").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "test_service", "name": "test_service"}]} + 200, + json={ + "items": [ + { + "id": "test_service", + "name": "test_service", + "asynchronous_execution": False, + "proximity_proof_required": False, + } + ] + }, ) ) diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 63e428b..363ebfc 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -98,6 +98,8 @@ class TestTriggersTools: "protocol": "sse", "status": "active", "trigger_condition": {"condition_type": "on_change"}, + "multishot": True, + "persistent": False, } @respx.mock @@ -425,6 +427,7 @@ class TestUpdatesTools: UPDATE_STATUS_RESPONSE = { "status": "inProgress", "progress": 50, + "x-medkit": {"phase": "executing"}, } @respx.mock @@ -656,6 +659,8 @@ async def test_triggers_dispatch_smoke(self, client: SovdClient) -> None: "protocol": "sse", "status": "active", "trigger_condition": {"condition_type": "on_change"}, + "multishot": True, + "persistent": False, } ] }, diff --git a/tests/test_tools.py b/tests/test_tools.py index 931aef8..0dff9d3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -119,7 +119,17 @@ async def test_list_entities_success(self, client: SovdClient) -> None: """Test successful entities listing.""" respx.get("http://test-sovd:8080/api/v1/areas").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "powertrain", "name": "powertrain", "type": "Area"}]} + 200, + json={ + "items": [ + { + "id": "powertrain", + "name": "powertrain", + "type": "area", + "href": "/areas/powertrain", + } + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/components").mock( @@ -127,15 +137,30 @@ async def test_list_entities_success(self, client: SovdClient) -> None: 200, json={ "items": [ - {"id": "temp_sensor", "name": "Temperature Sensor", "type": "Component"}, - {"id": "rpm_sensor", "name": "RPM Sensor", "type": "Component"}, + { + "id": "temp_sensor", + "name": "Temperature Sensor", + "type": "component", + "href": "/components/temp_sensor", + }, + { + "id": "rpm_sensor", + "name": "RPM Sensor", + "type": "component", + "href": "/components/rpm_sensor", + }, ] }, ) ) respx.get("http://test-sovd:8080/api/v1/apps").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "node_1", "name": "node_1", "type": "App"}]} + 200, + json={ + "items": [ + {"id": "node_1", "name": "node_1", "type": "app", "href": "/apps/node_1"} + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/functions").mock( @@ -152,13 +177,32 @@ async def test_list_entities_wrapped_response(self, client: SovdClient) -> None: """Test entities listing when some endpoints return errors.""" respx.get("http://test-sovd:8080/api/v1/areas").mock( return_value=httpx.Response( - 200, json={"items": [{"id": "powertrain", "name": "powertrain", "type": "Area"}]} + 200, + json={ + "items": [ + { + "id": "powertrain", + "name": "powertrain", + "type": "area", + "href": "/areas/powertrain", + } + ] + }, ) ) respx.get("http://test-sovd:8080/api/v1/components").mock( return_value=httpx.Response( 200, - json={"items": [{"id": "temp_sensor", "name": "temp_sensor", "type": "Component"}]}, + json={ + "items": [ + { + "id": "temp_sensor", + "name": "temp_sensor", + "type": "component", + "href": "/components/temp_sensor", + } + ] + }, ) ) # Apps and functions return 404 - should be caught @@ -192,7 +236,8 @@ async def test_get_entity_success(self, client: SovdClient) -> None: { "id": "temp_sensor", "name": "Temperature Sensor", - "type": "Component", + "type": "component", + "href": "/components/temp_sensor", } ] }, From cd7eb38e9f773c2a1887ca569f4996b5f1550141 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 11 Jun 2026 18:16:29 +0200 Subject: [PATCH 3/5] feat: expose query filters for faults, logs, and updates The list tools returned everything; an agent had no way to ask the gateway for a subset. Add the gateway's query filters to the fault/log/update list tools and client methods: - faults: status, include_muted, include_clusters - logs: severity, context - updates: origin, target-version The 0.5.0 generated client cannot pass query parameters - the spec omits them even though the gateway reads them (selfpatch/ros2_medkit#416) - so filtered calls go through raw httpx until the client is regenerated from the fixed spec. Unfiltered calls keep using the generated client. Tests assert the filters reach the gateway as query params. --- src/ros2_medkit_mcp/client.py | 80 ++++++++++++++++++++++++++++++++-- src/ros2_medkit_mcp/mcp_app.py | 79 ++++++++++++++++++++++++++++++--- src/ros2_medkit_mcp/models.py | 46 ++++++++++++++++++- tests/test_tools.py | 52 ++++++++++++++++++++++ 4 files changed, 245 insertions(+), 12 deletions(-) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index fe402de..3f04449 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -86,6 +86,20 @@ def _extract_items(result: Any) -> list[dict[str, Any]]: return [d] if d else [] +def _fault_query_params( + status: str | None, include_muted: bool, include_clusters: bool +) -> dict[str, str]: + """Build the fault-list query params shared by the entity and global lists.""" + params: dict[str, str] = {} + if status: + params["status"] = status + if include_muted: + params["include_muted"] = "true" + if include_clusters: + params["include_clusters"] = "true" + return params + + def _extract_filename(content_disposition: str) -> str | None: """Extract filename from Content-Disposition header.""" if "filename=" not in content_disposition: @@ -592,6 +606,26 @@ async def _raw_upload( except httpx.RequestError as e: raise SovdClientError(message=f"Request failed: {e}") from e + async def _raw_get_items(self, path: str, params: dict[str, str]) -> list[dict[str, Any]]: + """GET a collection via raw httpx with query parameters. + + The 0.5.0 generated client cannot pass query parameters: the spec omits + them even though the gateway reads them (see selfpatch/ros2_medkit#416). + Use raw httpx for filtered list calls until the client is regenerated + from the fixed spec. Path segments must be pre-encoded by the caller. + """ + try: + hc = await self._httpx_client() + response = await hc.get(path, params=params) + if not response.is_success: + raise SovdClientError( + message=f"Gateway returned HTTP {response.status_code}", + status_code=response.status_code, + ) + return _extract_items(response.json()) + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + # ==================== Server ==================== async def get_version(self) -> dict[str, Any]: @@ -693,8 +727,17 @@ async def list_component_dependencies(self, component_id: str) -> list[dict[str, # ==================== Faults ==================== async def list_faults( - self, entity_id: str, entity_type: str = "components" + self, + entity_id: str, + entity_type: str = "components", + status: str | None = None, + include_muted: bool = False, + include_clusters: bool = False, ) -> list[dict[str, Any]]: + params = _fault_query_params(status, include_muted, include_clusters) + if params: + path = f"/{quote(entity_type, safe='')}/{quote(entity_id, safe='')}/faults" + return await self._raw_get_items(path, params) fn = _entity_func("faults", "list", entity_type) return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) @@ -720,7 +763,15 @@ async def clear_all_faults( fn = _entity_func("faults", "clear_all", entity_type) return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) - async def list_all_faults(self) -> list[dict[str, Any]]: + async def list_all_faults( + self, + status: str | None = None, + include_muted: bool = False, + include_clusters: bool = False, + ) -> list[dict[str, Any]]: + params = _fault_query_params(status, include_muted, include_clusters) + if params: + return await self._raw_get_items("/faults", params) return _extract_items(await self._call(faults.list_all_faults.asyncio)) async def get_fault_snapshots( @@ -1017,8 +1068,20 @@ async def upload_bulk_data( # ==================== Logs ==================== async def list_logs( - self, entity_id: str, entity_type: str = "components" + self, + entity_id: str, + entity_type: str = "components", + severity: str | None = None, + context: str | None = None, ) -> list[dict[str, Any]]: + params: dict[str, str] = {} + if severity: + params["severity"] = severity + if context: + params["context"] = context + if params: + path = f"/{quote(entity_type, safe='')}/{quote(entity_id, safe='')}/logs" + return await self._raw_get_items(path, params) fn = _entity_func("logs", "list", entity_type) return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) @@ -1296,7 +1359,16 @@ async def delete_cyclic_subscription( # ==================== Software Updates ==================== - async def list_updates(self) -> list[dict[str, Any]]: + async def list_updates( + self, origin: str | None = None, target_version: str | None = None + ) -> list[dict[str, Any]]: + params: dict[str, str] = {} + if origin: + params["origin"] = origin + if target_version: + params["target-version"] = target_version + if params: + return await self._raw_get_items("/updates", params) return _extract_items(await self._call(updates.list_updates.asyncio)) async def register_update(self, update_config: dict[str, Any]) -> dict[str, Any]: diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 1c07a11..df0dc13 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -17,6 +17,7 @@ from ros2_medkit_mcp.config import Settings from ros2_medkit_mcp.models import ( AcquireLockArgs, + AllFaultsListArgs, AppIdArgs, AreaComponentsArgs, AreaContainsArgs, @@ -909,6 +910,21 @@ async def list_tools() -> list[Tool]: "description": "Entity type", "default": "components", }, + "status": { + "type": "string", + "enum": ["pending", "confirmed", "cleared", "healed", "all"], + "description": "Filter by fault status", + }, + "include_muted": { + "type": "boolean", + "description": "Include muted faults in the response", + "default": False, + }, + "include_clusters": { + "type": "boolean", + "description": "Include fault clusters in the response", + "default": False, + }, }, "required": ["entity_id"], }, @@ -966,7 +982,23 @@ async def list_tools() -> list[Tool]: description="List all faults across the entire system. Returns faults from all components.", inputSchema={ "type": "object", - "properties": {}, + "properties": { + "status": { + "type": "string", + "enum": ["pending", "confirmed", "cleared", "healed", "all"], + "description": "Filter by fault status", + }, + "include_muted": { + "type": "boolean", + "description": "Include muted faults in the response", + "default": False, + }, + "include_clusters": { + "type": "boolean", + "description": "Include fault clusters in the response", + "default": False, + }, + }, "required": [], }, ), @@ -1788,6 +1820,15 @@ async def list_tools() -> list[Tool]: "description": "Entity type", "default": "components", }, + "severity": { + "type": "string", + "enum": ["debug", "info", "warning", "error", "fatal"], + "description": "Filter by minimum severity", + }, + "context": { + "type": "string", + "description": "Filter by logger context substring (max 256 chars)", + }, }, "required": ["entity_id"], }, @@ -2453,7 +2494,16 @@ async def list_tools() -> list[Tool]: description="List all registered software updates.", inputSchema={ "type": "object", - "properties": {}, + "properties": { + "origin": { + "type": "string", + "description": "Filter by update origin identifier", + }, + "target_version": { + "type": "string", + "description": "Filter by target version", + }, + }, }, ), Tool( @@ -2658,7 +2708,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_faults_list": args = FaultsListArgs(**arguments) - faults = await client.list_faults(args.entity_id, args.entity_type) + faults = await client.list_faults( + args.entity_id, + args.entity_type, + status=args.status, + include_muted=args.include_muted, + include_clusters=args.include_clusters, + ) return format_fault_list(faults) elif normalized_name == "ros2_medkit_faults_get": @@ -2738,7 +2794,12 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Extended Faults ==================== elif normalized_name == "ros2_medkit_all_faults_list": - faults = await client.list_all_faults() + args = AllFaultsListArgs(**arguments) + faults = await client.list_all_faults( + status=args.status, + include_muted=args.include_muted, + include_clusters=args.include_clusters, + ) return format_fault_list(faults) elif normalized_name == "ros2_medkit_clear_all_faults": @@ -2938,7 +2999,9 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_list_logs": args = ListLogsArgs(**arguments) - result = await client.list_logs(args.entity_id, args.entity_type) + result = await client.list_logs( + args.entity_id, args.entity_type, severity=args.severity, context=args.context + ) return format_json_response(result) elif normalized_name == "ros2_medkit_get_log_configuration": @@ -3112,8 +3175,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Software Updates ==================== elif normalized_name == "ros2_medkit_list_updates": - ListUpdatesArgs(**arguments) - result = await client.list_updates() + args = ListUpdatesArgs(**arguments) + result = await client.list_updates( + origin=args.origin, target_version=args.target_version + ) return format_json_response(result) elif normalized_name == "ros2_medkit_register_update": diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index 0958b83..29dc42e 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -39,6 +39,35 @@ class FaultsListArgs(BaseModel): default="components", description="Entity type: 'components', 'apps', 'areas', or 'functions'", ) + status: str | None = Field( + default=None, + description="Filter by fault status: pending, confirmed, cleared, healed, or all", + ) + include_muted: bool = Field( + default=False, + description="Include muted faults in the response", + ) + include_clusters: bool = Field( + default=False, + description="Include fault clusters in the response", + ) + + +class AllFaultsListArgs(BaseModel): + """Arguments for listing all faults globally.""" + + status: str | None = Field( + default=None, + description="Filter by fault status: pending, confirmed, cleared, healed, or all", + ) + include_muted: bool = Field( + default=False, + description="Include muted faults in the response", + ) + include_clusters: bool = Field( + default=False, + description="Include fault clusters in the response", + ) class FaultGetArgs(BaseModel): @@ -824,6 +853,14 @@ class ListLogsArgs(BaseModel): default="components", description="Entity type: 'components', 'apps', 'areas', or 'functions'", ) + severity: str | None = Field( + default=None, + description="Filter by minimum severity: debug, info, warning, error, or fatal", + ) + context: str | None = Field( + default=None, + description="Filter by logger context substring (max 256 chars)", + ) class GetLogConfigurationArgs(BaseModel): @@ -1109,7 +1146,14 @@ class UpdateCyclicSubArgs(BaseModel): class ListUpdatesArgs(BaseModel): """Arguments for sovd_list_updates tool.""" - pass + origin: str | None = Field( + default=None, + description="Filter by update origin identifier", + ) + target_version: str | None = Field( + default=None, + description="Filter by target version", + ) class RegisterUpdateArgs(BaseModel): diff --git a/tests/test_tools.py b/tests/test_tools.py index 0dff9d3..7a9bc3b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -300,6 +300,58 @@ async def test_list_faults_success(self, client: SovdClient) -> None: assert result[0]["fault_code"] == "fault-1" await client.close() + @respx.mock + async def test_list_faults_sends_status_filter(self, client: SovdClient) -> None: + """A status/include_muted filter is sent as query params to the gateway.""" + route = respx.get( + "http://test-sovd:8080/api/v1/components/motor/faults", + params={"status": "confirmed", "include_muted": "true"}, + ).mock(return_value=httpx.Response(200, json={"items": [{"fault_code": "f1"}]})) + + result = await client.list_faults("motor", status="confirmed", include_muted=True) + + assert route.called + assert len(result) == 1 + await client.close() + + @respx.mock + async def test_list_all_faults_sends_status_filter(self, client: SovdClient) -> None: + """The global fault list forwards the status filter as a query param.""" + route = respx.get("http://test-sovd:8080/api/v1/faults", params={"status": "all"}).mock( + return_value=httpx.Response(200, json={"items": []}) + ) + + await client.list_all_faults(status="all") + + assert route.called + await client.close() + + @respx.mock + async def test_list_logs_sends_severity_filter(self, client: SovdClient) -> None: + """A severity/context filter is sent as query params to the gateway.""" + route = respx.get( + "http://test-sovd:8080/api/v1/components/motor/logs", + params={"severity": "error", "context": "engine"}, + ).mock(return_value=httpx.Response(200, json={"items": []})) + + await client.list_logs("motor", severity="error", context="engine") + + assert route.called + await client.close() + + @respx.mock + async def test_list_updates_sends_origin_filter(self, client: SovdClient) -> None: + """The update list forwards origin/target-version as query params.""" + route = respx.get( + "http://test-sovd:8080/api/v1/updates", + params={"origin": "fleet", "target-version": "2.0.0"}, + ).mock(return_value=httpx.Response(200, json={"items": []})) + + await client.list_updates(origin="fleet", target_version="2.0.0") + + assert route.called + await client.close() + @respx.mock async def test_list_faults_different_component(self, client: SovdClient) -> None: """Test faults listing for different component.""" From 5f9f641d3380ab45a0e831fdfce2b6824ae69e68 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 11 Jun 2026 19:20:15 +0200 Subject: [PATCH 4/5] fix(mcp): tighten 0.5.0 client wrapper contracts - Update actions (prepare/execute/automate) are body-less PUTs in 0.5.0; drop the dead `config` argument that the tools advertised but the client silently ignored, so an agent can no longer believe a config it passed was applied. - Void operations derive success from the HTTP status via the detailed client variant. The generated parsers return None for undocumented statuses (401/403/429/503/...), and the previous "None means success" path reported a false success on those - dangerous on destructive ops (fault clear, config delete, software update). Now only 2xx is success; everything else raises the SOVD error envelope. clear_fault, clear_all_faults, cancel_execution, delete_configuration and delete_all_configurations go through this path. - Raw httpx helpers (filtered lists, uploads) surface the SOVD error envelope ([error_code] message) instead of a bare HTTP status. - The per-entity faults_list tool drops include_muted/include_clusters: the gateway honors them only on the global /faults route, so advertising them per-entity was a silent no-op. They remain on the all-faults tool. --- src/ros2_medkit_mcp/client.py | 105 ++++++++++++++++++++++----------- src/ros2_medkit_mcp/mcp_app.py | 43 +++----------- src/ros2_medkit_mcp/models.py | 20 ------- tests/test_new_tools.py | 8 +-- tests/test_tools.py | 46 ++++++++++++++- 5 files changed, 122 insertions(+), 100 deletions(-) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 3f04449..5fdcd96 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -9,8 +9,10 @@ """ import asyncio +import json import logging import re +import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress from typing import Any @@ -100,6 +102,36 @@ def _fault_query_params( return params +def _error_from_content(status_code: int, content: bytes) -> str: + """Build an error message from an HTTP status and a raw response body. + + Prefers the SOVD error envelope (``[error_code] message``) so callers surface + the same diagnostics regardless of whether the response came from raw httpx + or the generated client. Falls back to the status plus a truncated body. + """ + try: + body = json.loads(content) + except (ValueError, TypeError): + text = content.decode("utf-8", "replace").strip() if content else "" + return ( + f"Gateway returned HTTP {status_code}: {text[:200]}" + if text + else f"Gateway returned HTTP {status_code}" + ) + if isinstance(body, dict) and body.get("error_code"): + return f"[{body['error_code']}] {body.get('message', '')}".strip() + return f"Gateway returned HTTP {status_code}" + + +def _gateway_error_message(response: httpx.Response) -> str: + """Build an error message from a gateway httpx error response. + + Surfaces the SOVD error envelope (``[error_code] message``) so raw httpx + calls report the same diagnostics as the generated-client path. + """ + return _error_from_content(response.status_code, response.content) + + def _extract_filename(content_disposition: str) -> str | None: """Extract filename from Content-Disposition header.""" if "filename=" not in content_disposition: @@ -523,26 +555,31 @@ async def _call(self, api_func: Any, **kwargs: Any) -> Any: raise SovdClientError(message=f"Failed to parse response: {e}") from e async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: - """Call a generated API function that may return 204/202 (None). - - Unlike _call(), this handles endpoints that return no body on success. - The generated client returns None for 204/202, which MedkitClient.call() - treats as an error. This method calls the function directly, treating - None as success and checking for GenericError responses. + """Call a generated API function whose success may be a body-less 204/202. + + Unlike _call(), this tolerates endpoints that return no body on success. + Success is derived from the HTTP status, NOT from a None parsed body: the + generated parsers also return None for any *undocumented* status (401, + 403, 429, 503, ...) when raise_on_unexpected_status is False, so keying + success off `parsed is None` would report success on those errors - a + silent false-success on destructive operations. Uses the ``_detailed`` + variant so the real status code is available; only 2xx is success. """ if "body" in kwargs and isinstance(kwargs["body"], dict): kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"]) client = await self._ensure_client() + detailed = sys.modules[api_func.__module__].asyncio_detailed try: - result = await api_func(client=client.http, **kwargs) - if result is None: + response = await detailed(client=client.http, **kwargs) + status = int(response.status_code) + if status >= 400: + raise SovdClientError( + message=_error_from_content(status, response.content), + status_code=status, + ) + if response.parsed is None: return {} - # Check for GenericError (gateway returned 4xx/5xx) - if hasattr(result, "error_code") and hasattr(result, "message"): - error_code = getattr(result, "error_code", "unknown") - message = getattr(result, "message", "Unknown error") - raise SovdClientError(message=f"[{error_code}] {message}") - return _to_dict(result) + return _to_dict(response.parsed) except httpx.TimeoutException as e: raise SovdClientError(message=f"Request timed out: {e}") from e except httpx.RequestError as e: @@ -558,7 +595,7 @@ async def _raw_request(self, method: str, path: str) -> Any: response = await hc.request(method, path) if not response.is_success: raise SovdClientError( - message=f"Gateway returned HTTP {response.status_code}", + message=_gateway_error_message(response), status_code=response.status_code, ) try: @@ -591,7 +628,7 @@ async def _raw_upload( response = await hc.post(path, files=files) if not response.is_success: raise SovdClientError( - message=f"Gateway returned HTTP {response.status_code}", + message=_gateway_error_message(response), status_code=response.status_code, ) if response.status_code == 204 or not response.content: @@ -619,7 +656,7 @@ async def _raw_get_items(self, path: str, params: dict[str, str]) -> list[dict[s response = await hc.get(path, params=params) if not response.is_success: raise SovdClientError( - message=f"Gateway returned HTTP {response.status_code}", + message=_gateway_error_message(response), status_code=response.status_code, ) return _extract_items(response.json()) @@ -731,13 +768,13 @@ async def list_faults( entity_id: str, entity_type: str = "components", status: str | None = None, - include_muted: bool = False, - include_clusters: bool = False, ) -> list[dict[str, Any]]: - params = _fault_query_params(status, include_muted, include_clusters) - if params: + # Only `status` is honored on the per-entity fault route; the gateway + # intentionally ignores include_muted/include_clusters there (they are + # global-only - see list_all_faults). + if status: path = f"/{quote(entity_type, safe='')}/{quote(entity_id, safe='')}/faults" - return await self._raw_get_items(path, params) + return await self._raw_get_items(path, {"status": status}) fn = _entity_func("faults", "list", entity_type) return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) @@ -753,7 +790,7 @@ async def clear_fault( self, entity_id: str, fault_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("faults", "clear", entity_type) - return await self._call( + return await self._call_void( fn, **{_entity_id_kwarg(entity_type): entity_id, "fault_code": fault_id} ) @@ -761,7 +798,7 @@ async def clear_all_faults( self, entity_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("faults", "clear_all", entity_type) - return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + return await self._call_void(fn, **{_entity_id_kwarg(entity_type): entity_id}) async def list_all_faults( self, @@ -903,7 +940,7 @@ async def cancel_execution( entity_type: str = "components", ) -> dict[str, Any]: fn = _entity_func("operations", "cancel_execution", entity_type) - return await self._call( + return await self._call_void( fn, **{ _entity_id_kwarg(entity_type): entity_id, @@ -946,7 +983,7 @@ async def delete_configuration( self, entity_id: str, param_name: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("configurations", "delete", entity_type) - return await self._call( + return await self._call_void( fn, **{_entity_id_kwarg(entity_type): entity_id, "config_id": param_name}, ) @@ -955,7 +992,7 @@ async def delete_all_configurations( self, entity_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("configurations", "delete_all", entity_type) - return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + return await self._call_void(fn, **{_entity_id_kwarg(entity_type): entity_id}) # ==================== Data Discovery ==================== @@ -1380,21 +1417,17 @@ async def get_update(self, update_id: str) -> dict[str, Any]: async def get_update_status(self, update_id: str) -> dict[str, Any]: return await self._call(updates.get_update_status.asyncio, update_id=update_id) - async def prepare_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: - # 0.5.0: prepare is a body-less PUT returning 202 Accepted. The endpoint - # no longer accepts a request body; config is retained for MCP tool - # compatibility but not transmitted. - del config + async def prepare_update(self, update_id: str) -> dict[str, Any]: + # 0.5.0: prepare is a body-less PUT returning 202 Accepted; the endpoint + # takes no request body. return await self._call_update_action(updates.prepare_update.asyncio, update_id=update_id) - async def execute_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: + async def execute_update(self, update_id: str) -> dict[str, Any]: # 0.5.0: execute is a body-less PUT returning 202 Accepted (see prepare_update). - del config return await self._call_update_action(updates.execute_update.asyncio, update_id=update_id) - async def automate_update(self, update_id: str, config: dict[str, Any]) -> dict[str, Any]: + async def automate_update(self, update_id: str) -> dict[str, Any]: # 0.5.0: automate is a body-less PUT returning 202 Accepted (see prepare_update). - del config return await self._call_update_action(updates.automate_update.asyncio, update_id=update_id) async def _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index df0dc13..0911d66 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -915,16 +915,6 @@ async def list_tools() -> list[Tool]: "enum": ["pending", "confirmed", "cleared", "healed", "all"], "description": "Filter by fault status", }, - "include_muted": { - "type": "boolean", - "description": "Include muted faults in the response", - "default": False, - }, - "include_clusters": { - "type": "boolean", - "description": "Include fault clusters in the response", - "default": False, - }, }, "required": ["entity_id"], }, @@ -2565,12 +2555,8 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "The update identifier", }, - "config": { - "type": "object", - "description": "Preparation configuration (e.g., {'verify_checksum': true})", - }, }, - "required": ["update_id", "config"], + "required": ["update_id"], }, ), Tool( @@ -2583,12 +2569,8 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "The update identifier", }, - "config": { - "type": "object", - "description": "Execution configuration (e.g., {'reboot_after': true})", - }, }, - "required": ["update_id", "config"], + "required": ["update_id"], }, ), Tool( @@ -2601,15 +2583,8 @@ async def list_tools() -> list[Tool]: "type": "string", "description": "The update identifier", }, - "config": { - "type": "object", - "description": ( - "Automation configuration" - " (e.g., {'verify_checksum': true, 'reboot_after': true})" - ), - }, }, - "required": ["update_id", "config"], + "required": ["update_id"], }, ), Tool( @@ -2709,11 +2684,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_faults_list": args = FaultsListArgs(**arguments) faults = await client.list_faults( - args.entity_id, - args.entity_type, - status=args.status, - include_muted=args.include_muted, - include_clusters=args.include_clusters, + args.entity_id, args.entity_type, status=args.status ) return format_fault_list(faults) @@ -3198,17 +3169,17 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_prepare_update": args = PrepareUpdateArgs(**arguments) - result = await client.prepare_update(args.update_id, args.config) + result = await client.prepare_update(args.update_id) return format_json_response(result) elif normalized_name == "ros2_medkit_execute_update": args = ExecuteUpdateArgs(**arguments) - result = await client.execute_update(args.update_id, args.config) + result = await client.execute_update(args.update_id) return format_json_response(result) elif normalized_name == "ros2_medkit_automate_update": args = AutomateUpdateArgs(**arguments) - result = await client.automate_update(args.update_id, args.config) + result = await client.automate_update(args.update_id) return format_json_response(result) elif normalized_name == "ros2_medkit_delete_update": diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index 29dc42e..5144e89 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -43,14 +43,6 @@ class FaultsListArgs(BaseModel): default=None, description="Filter by fault status: pending, confirmed, cleared, healed, or all", ) - include_muted: bool = Field( - default=False, - description="Include muted faults in the response", - ) - include_clusters: bool = Field( - default=False, - description="Include fault clusters in the response", - ) class AllFaultsListArgs(BaseModel): @@ -1184,30 +1176,18 @@ class PrepareUpdateArgs(BaseModel): """Arguments for sovd_prepare_update tool.""" update_id: str = Field(..., description="The update identifier") - config: dict[str, Any] = Field( - ..., - description="Preparation configuration (e.g., {'verify_checksum': true})", - ) class ExecuteUpdateArgs(BaseModel): """Arguments for sovd_execute_update tool.""" update_id: str = Field(..., description="The update identifier") - config: dict[str, Any] = Field( - ..., - description="Execution configuration (e.g., {'reboot_after': true})", - ) class AutomateUpdateArgs(BaseModel): """Arguments for sovd_automate_update tool.""" update_id: str = Field(..., description="The update identifier") - config: dict[str, Any] = Field( - ..., - description="Automation configuration (e.g., {'verify_checksum': true, 'reboot_after': true})", - ) class ToolResult(BaseModel): diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 363ebfc..57a1233 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -491,7 +491,7 @@ async def test_prepare_update(self, client: SovdClient) -> None: respx.put("http://test-sovd:8080/api/v1/updates/upd-1/prepare").mock( return_value=httpx.Response(202) ) - result = await client.prepare_update("upd-1", {"verify_checksum": True}) + result = await client.prepare_update("upd-1") assert result == {} await client.close() @@ -501,7 +501,7 @@ async def test_execute_update(self, client: SovdClient) -> None: respx.put("http://test-sovd:8080/api/v1/updates/upd-1/execute").mock( return_value=httpx.Response(202) ) - result = await client.execute_update("upd-1", {"reboot_after": True}) + result = await client.execute_update("upd-1") assert result == {} await client.close() @@ -511,9 +511,7 @@ async def test_automate_update(self, client: SovdClient) -> None: respx.put("http://test-sovd:8080/api/v1/updates/upd-1/automated").mock( return_value=httpx.Response(202) ) - result = await client.automate_update( - "upd-1", {"verify_checksum": True, "reboot_after": True} - ) + result = await client.automate_update("upd-1") assert result == {} await client.close() diff --git a/tests/test_tools.py b/tests/test_tools.py index 7a9bc3b..f1e33d0 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -302,13 +302,13 @@ async def test_list_faults_success(self, client: SovdClient) -> None: @respx.mock async def test_list_faults_sends_status_filter(self, client: SovdClient) -> None: - """A status/include_muted filter is sent as query params to the gateway.""" + """The per-entity status filter is sent as a query param to the gateway.""" route = respx.get( "http://test-sovd:8080/api/v1/components/motor/faults", - params={"status": "confirmed", "include_muted": "true"}, + params={"status": "confirmed"}, ).mock(return_value=httpx.Response(200, json={"items": [{"fault_code": "f1"}]})) - result = await client.list_faults("motor", status="confirmed", include_muted=True) + result = await client.list_faults("motor", status="confirmed") assert route.called assert len(result) == 1 @@ -352,6 +352,46 @@ async def test_list_updates_sends_origin_filter(self, client: SovdClient) -> Non assert route.called await client.close() + @respx.mock + async def test_filtered_call_surfaces_gateway_error_envelope(self, client: SovdClient) -> None: + """A filtered (raw httpx) call surfaces the SOVD error envelope, not a bare status.""" + respx.get( + "http://test-sovd:8080/api/v1/components/motor/faults", + params={"status": "bogus"}, + ).mock( + return_value=httpx.Response( + 400, json={"error_code": "invalid-parameter", "message": "Invalid status"} + ) + ) + + with pytest.raises(SovdClientError) as exc_info: + await client.list_faults("motor", status="bogus") + + assert "invalid-parameter" in str(exc_info.value) + assert "Invalid status" in str(exc_info.value) + await client.close() + + @respx.mock + async def test_void_call_raises_on_undocumented_error_status(self, client: SovdClient) -> None: + """A destructive op must raise on a 403, not silently report success. + + The generated parser returns None for statuses it does not enumerate + (401/403/429/...); _call_void must key success off the HTTP status, not a + None body, or destructive operations would report a false success. + """ + respx.delete("http://test-sovd:8080/api/v1/components/motor/faults").mock( + return_value=httpx.Response( + 403, json={"error_code": "forbidden", "message": "Insufficient role"} + ) + ) + + with pytest.raises(SovdClientError) as exc_info: + await client.clear_all_faults("motor") + + assert "forbidden" in str(exc_info.value) + assert exc_info.value.status_code == 403 + await client.close() + @respx.mock async def test_list_faults_different_component(self, client: SovdClient) -> None: """Test faults listing for different component.""" From 54c4c0276c95b1595430beef7759505a71a9b438 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 11 Jun 2026 19:45:07 +0200 Subject: [PATCH 5/5] docs: document uv and uvx usage in the README Add uv (uv pip install -e .) and uvx (uvx --from git+...) install and run paths alongside Poetry, including a uvx-based Claude Desktop config that needs no local checkout. The project uses the Poetry build backend, so uv installs via `uv pip install` rather than `uv sync`. --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 423f884..d0f46fa 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This server does **not** implement SOVD itself. It provides MCP tools that call ### Prerequisites - Python 3.11+ -- Poetry +- [Poetry](https://python-poetry.org/) or [uv](https://docs.astral.sh/uv/) - A running ros2_medkit gateway (default: `http://localhost:8080`) ### Installation @@ -35,10 +35,27 @@ This server does **not** implement SOVD itself. It provides MCP tools that call git clone https://github.com/selfpatch/ros2_medkit_mcp.git cd ros2_medkit_mcp -# Install dependencies +# Install dependencies with Poetry poetry install ``` +Or with [uv](https://docs.astral.sh/uv/): + +```bash +uv venv +uv pip install -e . # add '.[dev]' for the test and lint tools +``` + +> The project uses the Poetry build backend, so install it with `uv pip install` +> (not `uv sync`, which only reads PEP 621 `[project]` dependencies). The entry +> points then live in `.venv/bin/`. + +Don't want a checkout at all? Run it straight from the repository with `uvx`: + +```bash +uvx --from git+https://github.com/selfpatch/ros2_medkit_mcp ros2-medkit-mcp-stdio +``` + ### Configuration The server is configured via environment variables: @@ -55,6 +72,8 @@ The server is configured via environment variables: ```bash poetry run ros2-medkit-mcp-stdio +# uv: uv run --no-sync ros2-medkit-mcp-stdio (or: .venv/bin/ros2-medkit-mcp-stdio) +# uvx: uvx --from git+https://github.com/selfpatch/ros2_medkit_mcp ros2-medkit-mcp-stdio ``` For Claude Desktop, add to your `claude_desktop_config.json`: @@ -74,10 +93,32 @@ For Claude Desktop, add to your `claude_desktop_config.json`: } ``` +Or with `uvx`, which needs no local checkout (drop the `cwd`): + +```json +{ + "mcpServers": { + "ros2_medkit": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/selfpatch/ros2_medkit_mcp", + "ros2-medkit-mcp-stdio" + ], + "env": { + "ROS2_MEDKIT_BASE_URL": "http://localhost:8080/api/v1" + } + } + } +} +``` + #### Streamable HTTP Transport ```bash poetry run ros2-medkit-mcp-http --host 0.0.0.0 --port 8765 +# uv: uv run --no-sync ros2-medkit-mcp-http --host 0.0.0.0 --port 8765 +# uvx: uvx --from git+https://github.com/selfpatch/ros2_medkit_mcp ros2-medkit-mcp-http --host 0.0.0.0 --port 8765 ``` The server will be available at `http://0.0.0.0:8765/mcp`.