From b80b24b9d6fc59d911a54e2c27299384cdcb2130 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 25 Jun 2026 10:14:31 +0200 Subject: [PATCH] feat: add entity lifecycle status tools Expose ros2_medkit_status_get/set for apps and components (start, restart, force-restart, shutdown, force-shutdown), with sovd_* back-compat aliases. Wraps the gateway 0.6.0 lifecycle API; rejects other entity types and unknown actions. --- README.md | 25 +++++++- src/ros2_medkit_mcp/client.py | 78 +++++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 85 +++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 51 +++++++++++++++ tests/test_mcp_app.py | 43 +++++++++++++ tests/test_new_tools.py | 112 ++++++++++++++++++++++++++++++++- 6 files changed, 392 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0f46fa..c6ecf66 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This server does **not** implement SOVD itself. It provides MCP tools that call ## Features -- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), and configurations (ROS 2 parameters) +- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), configurations (ROS 2 parameters), and entity lifecycle status (apps/components) - **Dual transport support**: stdio and streamable-http - **Async HTTP client** using httpx - **Pydantic validation** for configuration and models @@ -382,6 +382,29 @@ Reset all configurations (parameters) to their default values. **Returns:** Response from `DELETE /components/{component_id}/configurations` +### Lifecycle Tools + +Lifecycle status is only available for apps and components (not areas or functions). + +#### `ros2_medkit_status_get` +Get the lifecycle status of an app or component (e.g. `ready` / `notReady`). + +**Arguments:** +- `entity_type` (required, string): `apps` or `components` +- `entity_id` (required, string): The entity identifier + +**Returns:** Response from `GET /{entity_type}/{entity_id}/status` + +#### `ros2_medkit_status_set` +Trigger a lifecycle transition on an app or component. **Warning:** `shutdown`, `force-shutdown`, `restart`, and `force-restart` affect the running node or host process. + +**Arguments:** +- `entity_type` (required, string): `apps` or `components` +- `entity_id` (required, string): The entity identifier +- `action` (required, string): one of `start`, `restart`, `force-restart`, `shutdown`, `force-shutdown` + +**Returns:** `202 Accepted` from `PUT /{entity_type}/{entity_id}/status/{action}` (no body) + ## MCP Resources ### `sovd://openapi` diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index d8e594d..88e926b 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -26,6 +26,7 @@ data, discovery, faults, + lifecycle, locking, logs, operations, @@ -457,8 +458,43 @@ def _validate_relative_uri(uri: str) -> None: "functions": subscriptions.delete_function_subscription, }, }, + # Lifecycle is exposed only for apps and components (no areas/functions). + # Action keys use the hyphenated SOVD action names (force-restart, + # force-shutdown); the generated modules use underscores. + "lifecycle": { + "get": { + "components": lifecycle.get_components_status, + "apps": lifecycle.get_apps_status, + }, + "start": { + "components": lifecycle.put_components_status_start, + "apps": lifecycle.put_apps_status_start, + }, + "restart": { + "components": lifecycle.put_components_status_restart, + "apps": lifecycle.put_apps_status_restart, + }, + "force-restart": { + "components": lifecycle.put_components_status_force_restart, + "apps": lifecycle.put_apps_status_force_restart, + }, + "shutdown": { + "components": lifecycle.put_components_status_shutdown, + "apps": lifecycle.put_apps_status_shutdown, + }, + "force-shutdown": { + "components": lifecycle.put_components_status_force_shutdown, + "apps": lifecycle.put_apps_status_force_shutdown, + }, + }, } +# Lifecycle is exposed only for apps and components. +_LIFECYCLE_ENTITY_TYPES = frozenset({"apps", "components"}) + +# Valid lifecycle transition actions (hyphenated SOVD action names). +_LIFECYCLE_ACTIONS = frozenset({"start", "restart", "force-restart", "shutdown", "force-shutdown"}) + # Validate all function references at import time for _resource, _methods in _ENTITY_FUNC_MAP.items(): @@ -1440,6 +1476,48 @@ async def _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, A async def delete_update(self, update_id: str) -> dict[str, Any]: return await self._call_void(updates.delete_update.asyncio, update_id=update_id) + # ==================== Lifecycle ==================== + + async def get_status(self, entity_type: str, entity_id: str) -> dict[str, Any]: + """Get the lifecycle status of an app or component. + + Lifecycle is exposed only for ``apps`` and ``components``; any other + entity_type raises SovdClientError. + """ + if entity_type not in _LIFECYCLE_ENTITY_TYPES: + raise SovdClientError( + message=( + f"Lifecycle status is only available for apps and components, " + f"not '{entity_type}'" + ) + ) + fn = _entity_func("lifecycle", "get", entity_type) + return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + + async def set_status(self, entity_type: str, entity_id: str, action: str) -> dict[str, Any]: + """Trigger a lifecycle transition on an app or component. + + ``action`` is one of start, restart, force-restart, shutdown, + force-shutdown. The transition PUTs are body-less and return 202. + Lifecycle is exposed only for ``apps`` and ``components``. + """ + if entity_type not in _LIFECYCLE_ENTITY_TYPES: + raise SovdClientError( + message=( + f"Lifecycle transitions are only available for apps and " + f"components, not '{entity_type}'" + ) + ) + if action not in _LIFECYCLE_ACTIONS: + raise SovdClientError( + message=( + f"Unknown lifecycle action '{action}'; expected one of " + f"{', '.join(sorted(_LIFECYCLE_ACTIONS))}" + ) + ) + fn = _entity_func("lifecycle", action, entity_type) + return await self._call_void(fn, **{_entity_id_kwarg(entity_type): entity_id}) + @asynccontextmanager async def create_client(settings: Settings) -> AsyncIterator[SovdClient]: diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 0911d66..1409114 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -82,6 +82,8 @@ RosbagSnapshot, SetConfigurationArgs, SetLogConfigurationArgs, + StatusGetArgs, + StatusSetArgs, SubareasArgs, SubcomponentsArgs, SystemFaultSnapshotsArgs, @@ -690,6 +692,8 @@ async def download_rosbags_for_fault( "ros2_medkit_execute_update": "ros2_medkit_execute_update", "ros2_medkit_automate_update": "ros2_medkit_automate_update", "ros2_medkit_delete_update": "ros2_medkit_delete_update", + "ros2_medkit_status_get": "ros2_medkit_status_get", + "ros2_medkit_status_set": "ros2_medkit_status_set", # Legacy sovd_* aliases (backwards compatibility) "sovd_version": "ros2_medkit_version", "sovd_health": "ros2_medkit_health", @@ -775,6 +779,8 @@ async def download_rosbags_for_fault( "sovd_execute_update": "ros2_medkit_execute_update", "sovd_automate_update": "ros2_medkit_automate_update", "sovd_delete_update": "ros2_medkit_delete_update", + "sovd_status_get": "ros2_medkit_status_get", + "sovd_status_set": "ros2_medkit_status_set", # Dot-notation aliases (legacy) "sovd.version": "ros2_medkit_version", "sovd.entities.list": "ros2_medkit_entities_list", @@ -2601,6 +2607,67 @@ async def list_tools() -> list[Tool]: "required": ["update_id"], }, ), + Tool( + name="ros2_medkit_status_get", + description=( + "Get the lifecycle status of an app or component" + " (e.g. ready / notReady). Lifecycle is only available for" + " apps and components." + ), + inputSchema={ + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "enum": ["apps", "components"], + "description": "Entity type: 'apps' or 'components'", + }, + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + }, + "required": ["entity_type", "entity_id"], + }, + ), + Tool( + name="ros2_medkit_status_set", + description=( + "Trigger a lifecycle transition on an app or component." + " WARNING: shutdown/force-shutdown/restart/force-restart" + " affect the running node or host process. Lifecycle is only" + " available for apps and components." + ), + inputSchema={ + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "enum": ["apps", "components"], + "description": "Entity type: 'apps' or 'components'", + }, + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "action": { + "type": "string", + "enum": [ + "start", + "restart", + "force-restart", + "shutdown", + "force-shutdown", + ], + "description": ( + "Lifecycle transition: 'start', 'restart'," + " 'force-restart', 'shutdown', or 'force-shutdown'" + ), + }, + }, + "required": ["entity_type", "entity_id", "action"], + }, + ), ] # Append plugin tools if plugins: @@ -3187,6 +3254,24 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.delete_update(args.update_id) return format_json_response(result) + # ==================== Lifecycle ==================== + + elif normalized_name == "ros2_medkit_status_get": + status_get_args = StatusGetArgs(**arguments) + result = await client.get_status( + status_get_args.entity_type.value, status_get_args.entity_id + ) + return format_json_response(result) + + elif normalized_name == "ros2_medkit_status_set": + status_set_args = StatusSetArgs(**arguments) + result = await client.set_status( + status_set_args.entity_type.value, + status_set_args.entity_id, + status_set_args.action.value, + ) + return format_json_response(result) + else: # Check plugin tool map before reporting unknown tool plugin = plugin_tool_map.get(normalized_name) diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index 5144e89..4bf2973 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -1190,6 +1190,57 @@ class AutomateUpdateArgs(BaseModel): update_id: str = Field(..., description="The update identifier") +# ==================== Lifecycle Argument Models ==================== + + +class LifecycleEntityType(str, Enum): + """Entity types that support the lifecycle status API. + + The gateway exposes lifecycle only for apps and components (not areas + or functions). + """ + + APPS = "apps" + COMPONENTS = "components" + + +class LifecycleAction(str, Enum): + """Lifecycle transition actions (hyphenated SOVD action names).""" + + START = "start" + RESTART = "restart" + FORCE_RESTART = "force-restart" + SHUTDOWN = "shutdown" + FORCE_SHUTDOWN = "force-shutdown" + + +class StatusGetArgs(BaseModel): + """Arguments for ros2_medkit_status_get tool.""" + + entity_type: LifecycleEntityType = Field( + ..., + description="Entity type: 'apps' or 'components'", + ) + entity_id: str = Field(..., description="The entity identifier") + + +class StatusSetArgs(BaseModel): + """Arguments for ros2_medkit_status_set tool.""" + + entity_type: LifecycleEntityType = Field( + ..., + description="Entity type: 'apps' or 'components'", + ) + entity_id: str = Field(..., description="The entity identifier") + action: LifecycleAction = Field( + ..., + description=( + "Lifecycle transition: 'start', 'restart', 'force-restart', " + "'shutdown', or 'force-shutdown'" + ), + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py index 1eb95cd..08e612a 100644 --- a/tests/test_mcp_app.py +++ b/tests/test_mcp_app.py @@ -4,6 +4,7 @@ import pytest import respx from mcp.types import TextContent +from pydantic import ValidationError from ros2_medkit_mcp.client import SovdClient, SovdClientError from ros2_medkit_mcp.config import Settings @@ -11,7 +12,11 @@ from ros2_medkit_mcp.models import ( EntitiesListArgs, FaultsListArgs, + LifecycleAction, + LifecycleEntityType, ListOperationsArgs, + StatusGetArgs, + StatusSetArgs, filter_entities, ) @@ -51,6 +56,13 @@ def test_legacy_sovd_aliases(self) -> None: assert TOOL_ALIASES.get("sovd_version") == "ros2_medkit_version" assert TOOL_ALIASES.get("sovd_entities_list") == "ros2_medkit_entities_list" + def test_lifecycle_aliases(self) -> None: + """Test lifecycle status tool aliases resolve to canonical names.""" + assert TOOL_ALIASES.get("ros2_medkit_status_get") == "ros2_medkit_status_get" + assert TOOL_ALIASES.get("ros2_medkit_status_set") == "ros2_medkit_status_set" + assert TOOL_ALIASES.get("sovd_status_get") == "ros2_medkit_status_get" + assert TOOL_ALIASES.get("sovd_status_set") == "ros2_medkit_status_set" + class TestFormatFunctions: """Tests for response formatting functions.""" @@ -303,3 +315,34 @@ def test_entities_list_args_optional_filter(self) -> None: args_with_filter = EntitiesListArgs(filter="test") assert args_with_filter.filter == "test" + + def test_status_get_args_valid(self) -> None: + """Test StatusGetArgs accepts apps and components entity types.""" + args = StatusGetArgs(entity_type="apps", entity_id="motor") + assert args.entity_type is LifecycleEntityType.APPS + assert args.entity_id == "motor" + + args = StatusGetArgs(entity_type="components", entity_id="ecu") + assert args.entity_type is LifecycleEntityType.COMPONENTS + + def test_status_get_args_rejects_invalid_entity_type(self) -> None: + """Test StatusGetArgs rejects entity types without lifecycle support.""" + with pytest.raises(ValidationError): + StatusGetArgs(entity_type="areas", entity_id="powertrain") + + def test_status_set_args_valid_actions(self) -> None: + """Test StatusSetArgs accepts all five lifecycle transitions.""" + for action, expected in ( + ("start", LifecycleAction.START), + ("restart", LifecycleAction.RESTART), + ("force-restart", LifecycleAction.FORCE_RESTART), + ("shutdown", LifecycleAction.SHUTDOWN), + ("force-shutdown", LifecycleAction.FORCE_SHUTDOWN), + ): + args = StatusSetArgs(entity_type="apps", entity_id="motor", action=action) + assert args.action is expected + + def test_status_set_args_rejects_invalid_action(self) -> None: + """Test StatusSetArgs rejects unknown actions.""" + with pytest.raises(ValidationError): + StatusSetArgs(entity_type="apps", entity_id="motor", action="bogus") diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 57a1233..7ec0bed 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -4,7 +4,7 @@ import pytest import respx -from ros2_medkit_mcp.client import SovdClient +from ros2_medkit_mcp.client import SovdClient, SovdClientError from ros2_medkit_mcp.config import Settings from ros2_medkit_mcp.mcp_app import format_json_response @@ -516,6 +516,116 @@ async def test_automate_update(self, client: SovdClient) -> None: await client.close() +class TestLifecycleTools: + """Tests for entity lifecycle status tools (apps and components only). + + The gateway 0.6.0 lifecycle API exposes ``GET /{et}/{id}/status`` (returns a + LifecycleStatusResponse with a required ``status`` enum) and + ``PUT /{et}/{id}/status/{action}`` transitions (202 No Content) for + et in {apps, components}. The action path uses hyphens (force-restart, + force-shutdown). + """ + + STATUS_RESPONSE = { + "status": "ready", + "start": "/apps/motor/status/start", + "restart": "/apps/motor/status/restart", + "force-restart": "/apps/motor/status/force-restart", + "shutdown": "/apps/motor/status/shutdown", + "force-shutdown": "/apps/motor/status/force-shutdown", + } + + @respx.mock + async def test_get_status_apps(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/motor/status").mock( + return_value=httpx.Response(200, json=self.STATUS_RESPONSE) + ) + result = await client.get_status("apps", "motor") + assert result["status"] == "ready" + await client.close() + + @respx.mock + async def test_get_status_components(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/ecu/status").mock( + return_value=httpx.Response(200, json={"status": "notReady"}) + ) + result = await client.get_status("components", "ecu") + assert result["status"] == "notReady" + await client.close() + + async def test_get_status_invalid_entity_type(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.get_status("areas", "powertrain") + await client.close() + + @respx.mock + async def test_set_status_start_apps(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/start").mock( + return_value=httpx.Response(202) + ) + result = await client.set_status("apps", "motor", "start") + assert result == {} + await client.close() + + @respx.mock + async def test_set_status_restart_components(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/components/ecu/status/restart").mock( + return_value=httpx.Response(202) + ) + result = await client.set_status("components", "ecu", "restart") + assert result == {} + await client.close() + + @respx.mock + async def test_set_status_force_restart_apps(self, client: SovdClient) -> None: + # The action enum uses a hyphen at the API level (force-restart) but the + # generated module name uses an underscore (put_apps_status_force_restart). + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/force-restart").mock( + return_value=httpx.Response(202) + ) + result = await client.set_status("apps", "motor", "force-restart") + assert result == {} + await client.close() + + @respx.mock + async def test_set_status_shutdown_components(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/components/ecu/status/shutdown").mock( + return_value=httpx.Response(202) + ) + result = await client.set_status("components", "ecu", "shutdown") + assert result == {} + await client.close() + + @respx.mock + async def test_set_status_force_shutdown_apps(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/apps/motor/status/force-shutdown").mock( + return_value=httpx.Response(202) + ) + result = await client.set_status("apps", "motor", "force-shutdown") + assert result == {} + await client.close() + + async def test_set_status_invalid_entity_type(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.set_status("functions", "navigation", "start") + await client.close() + + async def test_set_status_invalid_action(self, client: SovdClient) -> None: + with pytest.raises(SovdClientError): + await client.set_status("apps", "motor", "bogus") + await client.close() + + @respx.mock + async def test_get_status_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/motor/status").mock( + return_value=httpx.Response(200, json=self.STATUS_RESPONSE) + ) + result = await client.get_status("apps", "motor") + formatted = format_json_response(result) + assert "ready" in formatted[0].text + await client.close() + + class TestDataDiscoveryTools: """Tests for data discovery tools (categories and groups)."""