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`. 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 68652a7..fde6ee7 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" @@ -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/__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" diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 98bd66c..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 @@ -86,6 +88,50 @@ 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 _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: @@ -509,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: @@ -544,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: @@ -557,6 +608,61 @@ 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=_gateway_error_message(response), + 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 + + 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=_gateway_error_message(response), + 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]: @@ -612,7 +718,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} @@ -658,8 +764,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, ) -> list[dict[str, Any]]: + # 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, {"status": status}) fn = _entity_func("faults", "list", entity_type) return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) @@ -675,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} ) @@ -683,9 +798,17 @@ 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) -> 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( @@ -817,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, @@ -860,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}, ) @@ -869,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 ==================== @@ -973,31 +1096,29 @@ 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 ==================== 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})) @@ -1083,20 +1204,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( @@ -1286,7 +1396,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]: @@ -1298,25 +1417,18 @@ 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]: - # 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 - ) + 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]: - # execute_update returns 202 Accepted on success. - return await self._call_update_action( - updates.execute_update.asyncio, update_id=update_id, body=config - ) + 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). + 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 - ) + 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). + 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/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 1c07a11..0911d66 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,11 @@ 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", + }, }, "required": ["entity_id"], }, @@ -966,7 +972,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 +1810,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 +2484,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( @@ -2515,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( @@ -2533,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( @@ -2551,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( @@ -2658,7 +2683,9 @@ 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 + ) return format_fault_list(faults) elif normalized_name == "ros2_medkit_faults_get": @@ -2738,7 +2765,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 +2970,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 +3146,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": @@ -3133,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 0958b83..5144e89 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -39,6 +39,27 @@ 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", + ) + + +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 +845,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 +1138,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): @@ -1140,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_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..57a1233 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 @@ -488,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() @@ -498,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() @@ -508,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() @@ -656,6 +657,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..f1e33d0 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", } ] }, @@ -255,6 +300,98 @@ 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: + """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"}, + ).mock(return_value=httpx.Response(200, json={"items": [{"fault_code": "f1"}]})) + + result = await client.list_faults("motor", status="confirmed") + + 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_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."""