From 58445f8581dc3dd1d2998df46c39d93748611334 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 08:35:48 +0200 Subject: [PATCH 01/14] feat: add logs MCP tools (list_logs, get/set_log_configuration) --- src/ros2_medkit_mcp/client.py | 60 +++++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 88 ++++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 37 ++++++++++++++ tests/test_new_tools.py | 80 +++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 tests/test_new_tools.py diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index d3c1b71..1f93c8f 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -24,6 +24,7 @@ data, discovery, faults, + logs, operations, server, ) @@ -250,6 +251,26 @@ def _validate_relative_uri(uri: str) -> None: "functions": bulk_data.list_function_bulk_data_descriptors, }, }, + "logs": { + "list": { + "components": logs.list_component_logs, + "apps": logs.list_app_logs, + "areas": logs.list_area_logs, + "functions": logs.list_function_logs, + }, + "get_config": { + "components": logs.get_component_log_configuration, + "apps": logs.get_app_log_configuration, + "areas": logs.get_area_log_configuration, + "functions": logs.get_function_log_configuration, + }, + "set_config": { + "components": logs.set_component_log_configuration, + "apps": logs.set_app_log_configuration, + "areas": logs.set_area_log_configuration, + "functions": logs.set_function_log_configuration, + }, + }, } @@ -745,6 +766,45 @@ async def download_bulk_data(self, bulk_data_uri: str) -> tuple[bytes, str | Non return response.content, _extract_filename(response.headers.get("Content-Disposition", "")) + # ==================== Logs ==================== + + async def list_logs( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("logs", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def get_log_configuration( + self, entity_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("logs", "get_config", entity_type) + return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + + async def set_log_configuration( + self, entity_id: str, config: dict[str, Any], entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("logs", "set_config", entity_type) + # set_log_configuration returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + if isinstance(config, dict): + config = _wrap_body_dict(fn, config) + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{_entity_id_kwarg(entity_type): entity_id, "body": config}, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 7538902..d77af98 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -44,13 +44,16 @@ FreezeFrameSnapshot, FunctionIdArgs, GetConfigurationArgs, + GetLogConfigurationArgs, GetOperationArgs, ListConfigurationsArgs, ListExecutionsArgs, + ListLogsArgs, ListOperationsArgs, PublishTopicArgs, RosbagSnapshot, SetConfigurationArgs, + SetLogConfigurationArgs, SubareasArgs, SubcomponentsArgs, SystemFaultSnapshotsArgs, @@ -629,6 +632,10 @@ async def download_rosbags_for_fault( "sovd_bulkdata_info": "sovd_bulkdata_info", "sovd_bulkdata_download": "sovd_bulkdata_download", "sovd_bulkdata_download_for_fault": "sovd_bulkdata_download_for_fault", + # Logs + "sovd_list_logs": "sovd_list_logs", + "sovd_get_log_configuration": "sovd_get_log_configuration", + "sovd_set_log_configuration": "sovd_set_log_configuration", } @@ -1497,6 +1504,68 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "fault_code"], }, ), + # ==================== Logs ==================== + Tool( + name="sovd_list_logs", + description="List log entries for an entity. Returns recent log messages.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_get_log_configuration", + description="Get log configuration for an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_set_log_configuration", + description="Update log configuration for an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "config": { + "type": "object", + "description": "Log configuration settings (e.g., {'level': 'debug', 'max_entries': 1000})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "config"], + }, + ), ] # Append plugin tools if plugins: @@ -1825,6 +1894,25 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: client, args.entity_id, args.fault_code, args.entity_type, args.output_dir ) + # ==================== Logs ==================== + + elif normalized_name == "sovd_list_logs": + args = ListLogsArgs(**arguments) + result = await client.list_logs(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_get_log_configuration": + args = GetLogConfigurationArgs(**arguments) + result = await client.get_log_configuration(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_set_log_configuration": + args = SetLogConfigurationArgs(**arguments) + result = await client.set_log_configuration( + args.entity_id, args.config, args.entity_type + ) + 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 4a0d43d..d45719c 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -740,6 +740,43 @@ class BulkDataDownloadForFaultArgs(BaseModel): ) +# ==================== Logs Argument Models ==================== + + +class ListLogsArgs(BaseModel): + """Arguments for sovd_list_logs tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class GetLogConfigurationArgs(BaseModel): + """Arguments for sovd_get_log_configuration tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class SetLogConfigurationArgs(BaseModel): + """Arguments for sovd_set_log_configuration tool.""" + + entity_id: str = Field(..., description="The entity identifier") + config: dict[str, Any] = Field( + ..., + description="Log configuration settings (e.g., {'level': 'debug', 'max_entries': 1000})", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py new file mode 100644 index 0000000..0324144 --- /dev/null +++ b/tests/test_new_tools.py @@ -0,0 +1,80 @@ +"""Tests for new MCP tools (v0.2.0-v0.4.0 features).""" + +import httpx +import pytest +import respx + +from ros2_medkit_mcp.client import SovdClient +from ros2_medkit_mcp.config import Settings + + +@pytest.fixture +def settings() -> Settings: + return Settings( + base_url="http://test-sovd:8080/api/v1", + bearer_token=None, + timeout_seconds=5.0, + ) + + +@pytest.fixture +def client(settings: Settings) -> SovdClient: + return SovdClient(settings) + + +class TestLogsTools: + @respx.mock + async def test_list_logs(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/logs").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + { + "id": "log-001", + "timestamp": "2026-01-01T00:00:00Z", + "severity": "info", + "message": "Started", + } + ] + }, + ) + ) + result = await client.list_logs("motor") + assert len(result) == 1 + assert result[0]["severity"] == "info" + await client.close() + + @respx.mock + async def test_list_logs_apps_entity_type(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/my_node/logs").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + result = await client.list_logs("my_node", "apps") + assert result == [] + await client.close() + + @respx.mock + async def test_get_log_configuration(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/logs/configuration").mock( + return_value=httpx.Response( + 200, + json={"max_entries": 1000, "severity_filter": "info"}, + ) + ) + result = await client.get_log_configuration("motor") + assert result["severity_filter"] == "info" + assert result["max_entries"] == 1000 + await client.close() + + @respx.mock + async def test_set_log_configuration(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/components/motor/logs/configuration").mock( + return_value=httpx.Response(204) + ) + result = await client.set_log_configuration( + "motor", {"max_entries": 500, "severity_filter": "debug"} + ) + # 204 No Content returns empty dict + assert result == {} + await client.close() From 191c1954855df4683b558c18d252813c85089ae1 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 08:45:59 +0200 Subject: [PATCH 02/14] feat: add triggers MCP tools (create, list, get, update, delete) --- src/ros2_medkit_mcp/client.py | 97 ++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 159 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 53 +++++++++++ tests/test_new_tools.py | 77 ++++++++++++++++ 4 files changed, 386 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 1f93c8f..91d3ea1 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -27,6 +27,7 @@ logs, operations, server, + triggers, ) from ros2_medkit_mcp.config import Settings @@ -271,6 +272,38 @@ def _validate_relative_uri(uri: str) -> None: "functions": logs.set_function_log_configuration, }, }, + "triggers": { + "list": { + "components": triggers.list_component_triggers, + "apps": triggers.list_app_triggers, + "areas": triggers.list_area_triggers, + "functions": triggers.list_function_triggers, + }, + "get": { + "components": triggers.get_component_trigger, + "apps": triggers.get_app_trigger, + "areas": triggers.get_area_trigger, + "functions": triggers.get_function_trigger, + }, + "create": { + "components": triggers.create_component_trigger, + "apps": triggers.create_app_trigger, + "areas": triggers.create_area_trigger, + "functions": triggers.create_function_trigger, + }, + "update": { + "components": triggers.update_component_trigger, + "apps": triggers.update_app_trigger, + "areas": triggers.update_area_trigger, + "functions": triggers.update_function_trigger, + }, + "delete": { + "components": triggers.delete_component_trigger, + "apps": triggers.delete_app_trigger, + "areas": triggers.delete_area_trigger, + "functions": triggers.delete_function_trigger, + }, + }, } @@ -805,6 +838,70 @@ async def set_log_configuration( except (ValueError, KeyError) as e: raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Triggers ==================== + + async def list_triggers( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("triggers", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def get_trigger( + self, entity_id: str, trigger_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("triggers", "get", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "trigger_id": trigger_id} + ) + + async def create_trigger( + self, entity_id: str, trigger_config: dict[str, Any], entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("triggers", "create", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "body": trigger_config} + ) + + async def update_trigger( + self, + entity_id: str, + trigger_id: str, + trigger_config: dict[str, Any], + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("triggers", "update", entity_type) + return await self._call( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "trigger_id": trigger_id, + "body": trigger_config, + }, + ) + + async def delete_trigger( + self, entity_id: str, trigger_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("triggers", "delete", entity_type) + # delete_trigger returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{_entity_id_kwarg(entity_type): entity_id, "trigger_id": trigger_id}, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 d77af98..1f7542a 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -29,6 +29,7 @@ ComponentHostsArgs, ComponentIdArgs, CreateExecutionArgs, + CreateTriggerArgs, DependenciesArgs, EntitiesListArgs, EntityDataArgs, @@ -46,10 +47,12 @@ GetConfigurationArgs, GetLogConfigurationArgs, GetOperationArgs, + GetTriggerArgs, ListConfigurationsArgs, ListExecutionsArgs, ListLogsArgs, ListOperationsArgs, + ListTriggersArgs, PublishTopicArgs, RosbagSnapshot, SetConfigurationArgs, @@ -59,6 +62,7 @@ SystemFaultSnapshotsArgs, ToolResult, UpdateExecutionArgs, + UpdateTriggerArgs, filter_entities, ) from ros2_medkit_mcp.plugin import McpPlugin @@ -636,6 +640,12 @@ async def download_rosbags_for_fault( "sovd_list_logs": "sovd_list_logs", "sovd_get_log_configuration": "sovd_get_log_configuration", "sovd_set_log_configuration": "sovd_set_log_configuration", + # Triggers + "sovd_list_triggers": "sovd_list_triggers", + "sovd_get_trigger": "sovd_get_trigger", + "sovd_create_trigger": "sovd_create_trigger", + "sovd_update_trigger": "sovd_update_trigger", + "sovd_delete_trigger": "sovd_delete_trigger", } @@ -1566,6 +1576,122 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "config"], }, ), + # ==================== Triggers ==================== + Tool( + name="sovd_list_triggers", + description="List all triggers for an entity. Triggers monitor resource changes and generate events.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_get_trigger", + description="Get details of a specific trigger by ID.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "trigger_id": { + "type": "string", + "description": "The trigger identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "trigger_id"], + }, + ), + Tool( + name="sovd_create_trigger", + description="Create a new trigger on an entity. Triggers monitor resources and fire events on change.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "trigger_config": { + "type": "object", + "description": "Trigger configuration (e.g., {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "trigger_config"], + }, + ), + Tool( + name="sovd_update_trigger", + description="Update an existing trigger's configuration.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "trigger_id": { + "type": "string", + "description": "The trigger identifier", + }, + "trigger_config": { + "type": "object", + "description": "Updated trigger configuration", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "trigger_id", "trigger_config"], + }, + ), + Tool( + name="sovd_delete_trigger", + description="Delete a trigger from an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "trigger_id": { + "type": "string", + "description": "The trigger identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "trigger_id"], + }, + ), ] # Append plugin tools if plugins: @@ -1913,6 +2039,39 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) + # ==================== Triggers ==================== + + elif normalized_name == "sovd_list_triggers": + args = ListTriggersArgs(**arguments) + result = await client.list_triggers(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_get_trigger": + args = GetTriggerArgs(**arguments) + result = await client.get_trigger(args.entity_id, args.trigger_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_create_trigger": + args = CreateTriggerArgs(**arguments) + result = await client.create_trigger( + args.entity_id, args.trigger_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_update_trigger": + args = UpdateTriggerArgs(**arguments) + result = await client.update_trigger( + args.entity_id, args.trigger_id, args.trigger_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_delete_trigger": + args = GetTriggerArgs(**arguments) + result = await client.delete_trigger( + args.entity_id, args.trigger_id, args.entity_type + ) + 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 d45719c..2a09a39 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -777,6 +777,59 @@ class SetLogConfigurationArgs(BaseModel): ) +# ==================== Triggers Argument Models ==================== + + +class ListTriggersArgs(BaseModel): + """Arguments for sovd_list_triggers tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class GetTriggerArgs(BaseModel): + """Arguments for sovd_get_trigger tool.""" + + entity_id: str = Field(..., description="The entity identifier") + trigger_id: str = Field(..., description="The trigger identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class CreateTriggerArgs(BaseModel): + """Arguments for sovd_create_trigger tool.""" + + entity_id: str = Field(..., description="The entity identifier") + trigger_config: dict[str, Any] = Field( + ..., + description=( + "Trigger configuration" + " (e.g., {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60})" + ), + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class UpdateTriggerArgs(BaseModel): + """Arguments for sovd_update_trigger tool.""" + + entity_id: str = Field(..., description="The entity identifier") + trigger_id: str = Field(..., description="The trigger identifier") + trigger_config: dict[str, Any] = Field(..., description="Updated trigger configuration") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 0324144..df46527 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -78,3 +78,80 @@ async def test_set_log_configuration(self, client: SovdClient) -> None: # 204 No Content returns empty dict assert result == {} await client.close() + + +class TestTriggersTools: + """Tests for trigger management tools. + + The generated client models (Trigger, TriggerList, TriggerCreateRequest, + TriggerUpdateRequest) require specific SOVD-compliant fields in request/response + bodies. Mock responses must include all required fields for the model's from_dict() + to succeed. + """ + + # Reusable trigger response matching the generated Trigger model schema + TRIGGER_RESPONSE = { + "id": "t1", + "event_source": "/events", + "observed_resource": "/data/temperature", + "protocol": "sse", + "status": "active", + "trigger_condition": {"condition_type": "on_change"}, + } + + @respx.mock + async def test_list_triggers(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/triggers").mock( + return_value=httpx.Response( + 200, + json={"items": [self.TRIGGER_RESPONSE]}, + ) + ) + result = await client.list_triggers("motor") + assert len(result) == 1 + assert result[0]["id"] == "t1" + assert result[0]["observed_resource"] == "/data/temperature" + await client.close() + + @respx.mock + async def test_get_trigger(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/triggers/t1").mock( + return_value=httpx.Response(200, json=self.TRIGGER_RESPONSE) + ) + result = await client.get_trigger("motor", "t1") + assert result["id"] == "t1" + assert result["status"] == "active" + await client.close() + + @respx.mock + async def test_create_trigger(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/triggers").mock( + return_value=httpx.Response(201, json=self.TRIGGER_RESPONSE) + ) + result = await client.create_trigger( + "motor", + { + "resource": "/data/temperature", + "trigger_condition": {"condition_type": "on_change"}, + }, + ) + assert result["id"] == "t1" + await client.close() + + @respx.mock + async def test_update_trigger(self, client: SovdClient) -> None: + respx.put("http://test-sovd:8080/api/v1/components/motor/triggers/t1").mock( + return_value=httpx.Response(200, json=self.TRIGGER_RESPONSE) + ) + result = await client.update_trigger("motor", "t1", {"lifetime": 120}) + assert result["id"] == "t1" + await client.close() + + @respx.mock + async def test_delete_trigger(self, client: SovdClient) -> None: + respx.delete("http://test-sovd:8080/api/v1/components/motor/triggers/t1").mock( + return_value=httpx.Response(204) + ) + result = await client.delete_trigger("motor", "t1") + assert result == {} + await client.close() From 1ef99154ff0029fec79819e90d755334b6d2e23f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 08:59:54 +0200 Subject: [PATCH 03/14] feat: add scripts MCP tools (list, get, upload, execute, control) Add 6 new MCP tools for script management on components and apps: sovd_list_scripts, sovd_get_script, sovd_upload_script, sovd_execute_script, sovd_get_script_execution, and sovd_control_script_execution. Includes delete_script client method. --- src/ros2_medkit_mcp/client.py | 152 ++++++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 208 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 81 +++++++++++++ tests/test_new_tools.py | 105 +++++++++++++++++ 4 files changed, 546 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 91d3ea1..4ebeb61 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -26,6 +26,7 @@ faults, logs, operations, + scripts, server, triggers, ) @@ -304,6 +305,36 @@ def _validate_relative_uri(uri: str) -> None: "functions": triggers.delete_function_trigger, }, }, + "scripts": { + "list": { + "components": scripts.list_component_scripts, + "apps": scripts.list_app_scripts, + }, + "get": { + "components": scripts.get_component_script, + "apps": scripts.get_app_script, + }, + "upload": { + "components": scripts.upload_component_script, + "apps": scripts.upload_app_script, + }, + "execute": { + "components": scripts.start_component_script_execution, + "apps": scripts.start_app_script_execution, + }, + "get_execution": { + "components": scripts.get_component_script_execution, + "apps": scripts.get_app_script_execution, + }, + "control_execution": { + "components": scripts.control_component_script_execution, + "apps": scripts.control_app_script_execution, + }, + "delete": { + "components": scripts.delete_component_script, + "apps": scripts.delete_app_script, + }, + }, } @@ -902,6 +933,127 @@ async def delete_trigger( except (ValueError, KeyError) as e: raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Scripts ==================== + + async def list_scripts( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("scripts", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def get_script( + self, entity_id: str, script_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("scripts", "get", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "script_id": script_id} + ) + + 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", + ) + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{_entity_id_kwarg(entity_type): entity_id, "body": file_obj}, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + + async def execute_script( + self, + entity_id: str, + script_id: str, + params: dict[str, Any] | None = None, + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("scripts", "execute", entity_type) + kwargs: dict[str, Any] = { + _entity_id_kwarg(entity_type): entity_id, + "script_id": script_id, + "body": params if params else {}, + } + return await self._call(fn, **kwargs) + + async def get_script_execution( + self, + entity_id: str, + script_id: str, + execution_id: str, + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("scripts", "get_execution", entity_type) + return await self._call( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "script_id": script_id, + "execution_id": execution_id, + }, + ) + + async def control_script_execution( + self, + entity_id: str, + script_id: str, + execution_id: str, + action: dict[str, Any], + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("scripts", "control_execution", entity_type) + return await self._call( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "script_id": script_id, + "execution_id": execution_id, + "body": action, + }, + ) + + async def delete_script( + self, entity_id: str, script_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("scripts", "delete", entity_type) + # delete_script returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{_entity_id_kwarg(entity_type): entity_id, "script_id": script_id}, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 1f7542a..2b6fd8b 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -28,6 +28,7 @@ ClearAllFaultsArgs, ComponentHostsArgs, ComponentIdArgs, + ControlScriptExecutionArgs, CreateExecutionArgs, CreateTriggerArgs, DependenciesArgs, @@ -36,6 +37,7 @@ EntityGetArgs, EntityTopicDataArgs, EnvironmentData, + ExecuteScriptArgs, ExecutionArgs, ExtendedDataRecords, FaultGetArgs, @@ -47,11 +49,14 @@ GetConfigurationArgs, GetLogConfigurationArgs, GetOperationArgs, + GetScriptArgs, + GetScriptExecutionArgs, GetTriggerArgs, ListConfigurationsArgs, ListExecutionsArgs, ListLogsArgs, ListOperationsArgs, + ListScriptsArgs, ListTriggersArgs, PublishTopicArgs, RosbagSnapshot, @@ -63,6 +68,7 @@ ToolResult, UpdateExecutionArgs, UpdateTriggerArgs, + UploadScriptArgs, filter_entities, ) from ros2_medkit_mcp.plugin import McpPlugin @@ -646,6 +652,13 @@ async def download_rosbags_for_fault( "sovd_create_trigger": "sovd_create_trigger", "sovd_update_trigger": "sovd_update_trigger", "sovd_delete_trigger": "sovd_delete_trigger", + # Scripts + "sovd_list_scripts": "sovd_list_scripts", + "sovd_get_script": "sovd_get_script", + "sovd_upload_script": "sovd_upload_script", + "sovd_execute_script": "sovd_execute_script", + "sovd_get_script_execution": "sovd_get_script_execution", + "sovd_control_script_execution": "sovd_control_script_execution", } @@ -1692,6 +1705,157 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "trigger_id"], }, ), + # ==================== Scripts ==================== + Tool( + name="sovd_list_scripts", + description="List all scripts for an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_get_script", + description="Get details of a specific script.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_id": { + "type": "string", + "description": "The script identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "script_id"], + }, + ), + Tool( + name="sovd_upload_script", + description="Upload a script to an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_content": { + "type": "string", + "description": "The script content as a string (will be uploaded as binary)", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "script_content"], + }, + ), + Tool( + name="sovd_execute_script", + description="Execute a script on an entity. Returns execution ID.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_id": { + "type": "string", + "description": "The script identifier", + }, + "params": { + "type": "object", + "description": "Optional parameters to pass to the script execution", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "script_id"], + }, + ), + Tool( + name="sovd_get_script_execution", + description="Get the status and result of a script execution.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_id": { + "type": "string", + "description": "The script identifier", + }, + "execution_id": { + "type": "string", + "description": "The execution identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "script_id", "execution_id"], + }, + ), + Tool( + name="sovd_control_script_execution", + description="Control a running script execution (stop, pause, etc.).", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_id": { + "type": "string", + "description": "The script identifier", + }, + "execution_id": { + "type": "string", + "description": "The execution identifier", + }, + "action": { + "type": "object", + "description": "Control action (e.g., {'command': 'stop'} or {'command': 'pause'})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "script_id", "execution_id", "action"], + }, + ), ] # Append plugin tools if plugins: @@ -2072,6 +2236,50 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) + # ==================== Scripts ==================== + + elif normalized_name == "sovd_list_scripts": + args = ListScriptsArgs(**arguments) + result = await client.list_scripts(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_get_script": + args = GetScriptArgs(**arguments) + result = await client.get_script(args.entity_id, args.script_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_upload_script": + args = UploadScriptArgs(**arguments) + result = await client.upload_script( + args.entity_id, args.script_content, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_execute_script": + args = ExecuteScriptArgs(**arguments) + result = await client.execute_script( + args.entity_id, args.script_id, args.params, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_get_script_execution": + args = GetScriptExecutionArgs(**arguments) + result = await client.get_script_execution( + args.entity_id, args.script_id, args.execution_id, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_control_script_execution": + args = ControlScriptExecutionArgs(**arguments) + result = await client.control_script_execution( + args.entity_id, + args.script_id, + args.execution_id, + args.action, + args.entity_type, + ) + 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 2a09a39..6515296 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -830,6 +830,87 @@ class UpdateTriggerArgs(BaseModel): ) +# ==================== Scripts Argument Models ==================== + + +class ListScriptsArgs(BaseModel): + """Arguments for sovd_list_scripts tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class GetScriptArgs(BaseModel): + """Arguments for sovd_get_script tool.""" + + entity_id: str = Field(..., description="The entity identifier") + script_id: str = Field(..., description="The script identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class UploadScriptArgs(BaseModel): + """Arguments for sovd_upload_script tool.""" + + entity_id: str = Field(..., description="The entity identifier") + script_content: str = Field( + ..., + description="The script content as a string (will be uploaded as binary)", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class ExecuteScriptArgs(BaseModel): + """Arguments for sovd_execute_script tool.""" + + entity_id: str = Field(..., description="The entity identifier") + script_id: str = Field(..., description="The script identifier") + params: dict[str, Any] | None = Field( + default=None, + description="Optional parameters to pass to the script execution", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class GetScriptExecutionArgs(BaseModel): + """Arguments for sovd_get_script_execution tool.""" + + entity_id: str = Field(..., description="The entity identifier") + script_id: str = Field(..., description="The script identifier") + execution_id: str = Field(..., description="The execution identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class ControlScriptExecutionArgs(BaseModel): + """Arguments for sovd_control_script_execution tool.""" + + entity_id: str = Field(..., description="The entity identifier") + script_id: str = Field(..., description="The script identifier") + execution_id: str = Field(..., description="The execution identifier") + action: dict[str, Any] = Field( + ..., + description="Control action (e.g., {'command': 'stop'} or {'command': 'pause'})", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index df46527..fa8cd1f 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -155,3 +155,108 @@ async def test_delete_trigger(self, client: SovdClient) -> None: result = await client.delete_trigger("motor", "t1") assert result == {} await client.close() + + +class TestScriptsTools: + """Tests for script management tools. + + Scripts are only supported on components and apps (not areas or functions). + """ + + SCRIPT_METADATA = { + "id": "s1", + "name": "diagnostics.py", + "content_type": "application/octet-stream", + "size": 1024, + } + + SCRIPT_EXECUTION = { + "id": "exec-1", + "script_id": "s1", + "status": "running", + "started_at": "2026-01-01T00:00:00Z", + } + + @respx.mock + async def test_list_scripts(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/scripts").mock( + return_value=httpx.Response( + 200, + json={"items": [self.SCRIPT_METADATA]}, + ) + ) + result = await client.list_scripts("motor") + assert len(result) == 1 + assert result[0]["id"] == "s1" + await client.close() + + @respx.mock + async def test_list_scripts_apps(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/my_node/scripts").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + result = await client.list_scripts("my_node", "apps") + assert result == [] + await client.close() + + @respx.mock + async def test_get_script(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/scripts/s1").mock( + return_value=httpx.Response(200, json=self.SCRIPT_METADATA) + ) + result = await client.get_script("motor", "s1") + assert result["id"] == "s1" + assert result["name"] == "diagnostics.py" + await client.close() + + @respx.mock + async def test_upload_script(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/scripts").mock( + return_value=httpx.Response( + 201, + json={"id": "s2", "name": "uploaded.py"}, + ) + ) + result = await client.upload_script("motor", "print('hello')") + assert result["id"] == "s2" + await client.close() + + @respx.mock + async def test_execute_script(self, client: SovdClient) -> None: + # Generated client expects 202 Accepted for script execution start + respx.post("http://test-sovd:8080/api/v1/components/motor/scripts/s1/executions").mock( + return_value=httpx.Response(202, json=self.SCRIPT_EXECUTION) + ) + result = await client.execute_script("motor", "s1", {"timeout": 30}) + assert result["id"] == "exec-1" + assert result["status"] == "running" + await client.close() + + @respx.mock + async def test_get_script_execution(self, client: SovdClient) -> None: + respx.get( + "http://test-sovd:8080/api/v1/components/motor/scripts/s1/executions/exec-1" + ).mock(return_value=httpx.Response(200, json=self.SCRIPT_EXECUTION)) + result = await client.get_script_execution("motor", "s1", "exec-1") + assert result["id"] == "exec-1" + assert result["status"] == "running" + await client.close() + + @respx.mock + async def test_control_script_execution(self, client: SovdClient) -> None: + execution_stopped = {**self.SCRIPT_EXECUTION, "status": "stopped"} + respx.put( + "http://test-sovd:8080/api/v1/components/motor/scripts/s1/executions/exec-1" + ).mock(return_value=httpx.Response(200, json=execution_stopped)) + result = await client.control_script_execution("motor", "s1", "exec-1", {"command": "stop"}) + assert result["status"] == "stopped" + await client.close() + + @respx.mock + async def test_delete_script(self, client: SovdClient) -> None: + respx.delete("http://test-sovd:8080/api/v1/components/motor/scripts/s1").mock( + return_value=httpx.Response(204) + ) + result = await client.delete_script("motor", "s1") + assert result == {} + await client.close() From c50bed1d4e04b7b59bcbf7eefb5759a0ef6a6138 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 09:08:54 +0200 Subject: [PATCH 04/14] feat: add locking MCP tools (acquire, list, get, extend, release) --- src/ros2_medkit_mcp/client.py | 87 ++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 157 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 53 +++++++++++ tests/test_new_tools.py | 82 +++++++++++++++++ 4 files changed, 379 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 4ebeb61..b1e919a 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -24,6 +24,7 @@ data, discovery, faults, + locking, logs, operations, scripts, @@ -335,6 +336,28 @@ def _validate_relative_uri(uri: str) -> None: "apps": scripts.delete_app_script, }, }, + "locking": { + "acquire": { + "components": locking.acquire_component_lock, + "apps": locking.acquire_app_lock, + }, + "list": { + "components": locking.list_component_locks, + "apps": locking.list_app_locks, + }, + "get": { + "components": locking.get_component_lock, + "apps": locking.get_app_lock, + }, + "extend": { + "components": locking.extend_component_lock, + "apps": locking.extend_app_lock, + }, + "release": { + "components": locking.release_component_lock, + "apps": locking.release_app_lock, + }, + }, } @@ -1054,6 +1077,70 @@ async def delete_script( except (ValueError, KeyError) as e: raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Locking ==================== + + async def acquire_lock( + self, entity_id: str, lock_config: dict[str, Any], entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("locking", "acquire", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "body": lock_config} + ) + + async def list_locks( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("locking", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def get_lock( + self, entity_id: str, lock_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("locking", "get", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id} + ) + + async def extend_lock( + self, + entity_id: str, + lock_id: str, + lock_config: dict[str, Any], + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("locking", "extend", entity_type) + return await self._call( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "lock_id": lock_id, + "body": lock_config, + }, + ) + + async def release_lock( + self, entity_id: str, lock_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("locking", "release", entity_type) + # release_lock returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{_entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id}, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 2b6fd8b..1d588a0 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -15,6 +15,7 @@ from ros2_medkit_mcp.client import SovdClient, SovdClientError from ros2_medkit_mcp.config import Settings from ros2_medkit_mcp.models import ( + AcquireLockArgs, AppIdArgs, AreaComponentsArgs, AreaContainsArgs, @@ -40,6 +41,7 @@ ExecuteScriptArgs, ExecutionArgs, ExtendedDataRecords, + ExtendLockArgs, FaultGetArgs, FaultItem, FaultsListArgs, @@ -47,6 +49,7 @@ FreezeFrameSnapshot, FunctionIdArgs, GetConfigurationArgs, + GetLockArgs, GetLogConfigurationArgs, GetOperationArgs, GetScriptArgs, @@ -54,6 +57,7 @@ GetTriggerArgs, ListConfigurationsArgs, ListExecutionsArgs, + ListLocksArgs, ListLogsArgs, ListOperationsArgs, ListScriptsArgs, @@ -659,6 +663,12 @@ async def download_rosbags_for_fault( "sovd_execute_script": "sovd_execute_script", "sovd_get_script_execution": "sovd_get_script_execution", "sovd_control_script_execution": "sovd_control_script_execution", + # Locking + "sovd_acquire_lock": "sovd_acquire_lock", + "sovd_list_locks": "sovd_list_locks", + "sovd_get_lock": "sovd_get_lock", + "sovd_extend_lock": "sovd_extend_lock", + "sovd_release_lock": "sovd_release_lock", } @@ -1856,6 +1866,122 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "script_id", "execution_id", "action"], }, ), + # ==================== Locking ==================== + Tool( + name="sovd_acquire_lock", + description="Acquire an exclusive lock on an entity for safe modifications.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "lock_config": { + "type": "object", + "description": "Lock configuration (e.g., {'duration': 60, 'reason': 'maintenance'})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "lock_config"], + }, + ), + Tool( + name="sovd_list_locks", + description="List all active locks on an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_get_lock", + description="Get details of a specific lock.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "lock_id": { + "type": "string", + "description": "The lock identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "lock_id"], + }, + ), + Tool( + name="sovd_extend_lock", + description="Extend the duration of an existing lock.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "lock_id": { + "type": "string", + "description": "The lock identifier", + }, + "lock_config": { + "type": "object", + "description": "Lock extension configuration (e.g., {'duration': 120})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "lock_id", "lock_config"], + }, + ), + Tool( + name="sovd_release_lock", + description="Release a lock on an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "lock_id": { + "type": "string", + "description": "The lock identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "components", + }, + }, + "required": ["entity_id", "lock_id"], + }, + ), ] # Append plugin tools if plugins: @@ -2280,6 +2406,37 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) + # ==================== Locking ==================== + + elif normalized_name == "sovd_acquire_lock": + args = AcquireLockArgs(**arguments) + result = await client.acquire_lock( + args.entity_id, args.lock_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_list_locks": + args = ListLocksArgs(**arguments) + result = await client.list_locks(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_get_lock": + args = GetLockArgs(**arguments) + result = await client.get_lock(args.entity_id, args.lock_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_extend_lock": + args = ExtendLockArgs(**arguments) + result = await client.extend_lock( + args.entity_id, args.lock_id, args.lock_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_release_lock": + args = GetLockArgs(**arguments) + result = await client.release_lock(args.entity_id, args.lock_id, args.entity_type) + 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 6515296..7eed285 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -911,6 +911,59 @@ class ControlScriptExecutionArgs(BaseModel): ) +# ==================== Locking Argument Models ==================== + + +class AcquireLockArgs(BaseModel): + """Arguments for sovd_acquire_lock tool.""" + + entity_id: str = Field(..., description="The entity identifier") + lock_config: dict[str, Any] = Field( + ..., + description="Lock configuration (e.g., {'duration': 60, 'reason': 'maintenance'})", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class ListLocksArgs(BaseModel): + """Arguments for sovd_list_locks tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class GetLockArgs(BaseModel): + """Arguments for sovd_get_lock and sovd_release_lock tools.""" + + entity_id: str = Field(..., description="The entity identifier") + lock_id: str = Field(..., description="The lock identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + +class ExtendLockArgs(BaseModel): + """Arguments for sovd_extend_lock tool.""" + + entity_id: str = Field(..., description="The entity identifier") + lock_id: str = Field(..., description="The lock identifier") + lock_config: dict[str, Any] = Field( + ..., + description="Lock extension configuration (e.g., {'duration': 120})", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components' or 'apps'", + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index fa8cd1f..6e3db0c 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -260,3 +260,85 @@ async def test_delete_script(self, client: SovdClient) -> None: result = await client.delete_script("motor", "s1") assert result == {} await client.close() + + +class TestLockingTools: + """Tests for lock management tools. + + Locks are only supported on components and apps (not areas or functions). + """ + + # Lock model requires: id, lock_expiration (ISO 8601), owned (bool) + LOCK_RESPONSE = { + "id": "lock-1", + "lock_expiration": "2026-01-01T01:00:00Z", + "owned": True, + "scopes": ["write"], + } + + LOCK_REQUEST = { + "id": "lock-1", + "lock_expiration": "2026-01-01T01:00:00Z", + "owned": True, + } + + @respx.mock + async def test_acquire_lock(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/locks").mock( + return_value=httpx.Response(201, json=self.LOCK_RESPONSE) + ) + result = await client.acquire_lock("motor", self.LOCK_REQUEST) + assert result["id"] == "lock-1" + assert result["owned"] is True + await client.close() + + @respx.mock + async def test_list_locks(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/locks").mock( + return_value=httpx.Response( + 200, + json={"items": [self.LOCK_RESPONSE]}, + ) + ) + result = await client.list_locks("motor") + assert len(result) == 1 + assert result[0]["id"] == "lock-1" + await client.close() + + @respx.mock + async def test_get_lock(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/locks/lock-1").mock( + return_value=httpx.Response(200, json=self.LOCK_RESPONSE) + ) + result = await client.get_lock("motor", "lock-1") + assert result["id"] == "lock-1" + assert result["owned"] is True + await client.close() + + @respx.mock + async def test_extend_lock(self, client: SovdClient) -> None: + extended_response = { + **self.LOCK_RESPONSE, + "lock_expiration": "2026-01-01T02:00:00Z", + } + extend_body = { + "id": "lock-1", + "lock_expiration": "2026-01-01T02:00:00Z", + "owned": True, + } + respx.put("http://test-sovd:8080/api/v1/components/motor/locks/lock-1").mock( + return_value=httpx.Response(200, json=extended_response) + ) + result = await client.extend_lock("motor", "lock-1", extend_body) + assert result["id"] == "lock-1" + assert result["lock_expiration"] == "2026-01-01T02:00:00+00:00" + await client.close() + + @respx.mock + async def test_release_lock(self, client: SovdClient) -> None: + respx.delete("http://test-sovd:8080/api/v1/components/motor/locks/lock-1").mock( + return_value=httpx.Response(204) + ) + result = await client.release_lock("motor", "lock-1") + assert result == {} + await client.close() From e486c5cfe5036fea33ba249ab3fc9dcb79839296 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 09:14:48 +0200 Subject: [PATCH 05/14] feat: add cyclic subscription MCP tools (create, list, get, update, delete) --- src/ros2_medkit_mcp/client.py | 99 ++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 161 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 53 +++++++++++ tests/test_new_tools.py | 72 +++++++++++++++ 4 files changed, 385 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index b1e919a..7a6c96e 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -29,6 +29,7 @@ operations, scripts, server, + subscriptions, triggers, ) @@ -358,6 +359,33 @@ def _validate_relative_uri(uri: str) -> None: "apps": locking.release_app_lock, }, }, + "subscriptions": { + "create": { + "components": subscriptions.create_component_subscription, + "apps": subscriptions.create_app_subscription, + "functions": subscriptions.create_function_subscription, + }, + "list": { + "components": subscriptions.list_component_subscriptions, + "apps": subscriptions.list_app_subscriptions, + "functions": subscriptions.list_function_subscriptions, + }, + "get": { + "components": subscriptions.get_component_subscription, + "apps": subscriptions.get_app_subscription, + "functions": subscriptions.get_function_subscription, + }, + "update": { + "components": subscriptions.update_component_subscription, + "apps": subscriptions.update_app_subscription, + "functions": subscriptions.update_function_subscription, + }, + "delete": { + "components": subscriptions.delete_component_subscription, + "apps": subscriptions.delete_app_subscription, + "functions": subscriptions.delete_function_subscription, + }, + }, } @@ -1141,6 +1169,77 @@ async def release_lock( except (ValueError, KeyError) as e: raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Cyclic Subscriptions ==================== + + async def create_cyclic_subscription( + self, + entity_id: str, + sub_config: dict[str, Any], + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("subscriptions", "create", entity_type) + return await self._call( + fn, **{_entity_id_kwarg(entity_type): entity_id, "body": sub_config} + ) + + async def list_cyclic_subscriptions( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("subscriptions", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def get_cyclic_subscription( + self, entity_id: str, subscription_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("subscriptions", "get", entity_type) + return await self._call( + fn, + **{_entity_id_kwarg(entity_type): entity_id, "subscription_id": subscription_id}, + ) + + async def update_cyclic_subscription( + self, + entity_id: str, + subscription_id: str, + sub_config: dict[str, Any], + entity_type: str = "components", + ) -> dict[str, Any]: + fn = _entity_func("subscriptions", "update", entity_type) + return await self._call( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "subscription_id": subscription_id, + "body": sub_config, + }, + ) + + async def delete_cyclic_subscription( + self, entity_id: str, subscription_id: str, entity_type: str = "components" + ) -> dict[str, Any]: + fn = _entity_func("subscriptions", "delete", entity_type) + # delete_subscription returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{ + _entity_id_kwarg(entity_type): entity_id, + "subscription_id": subscription_id, + }, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 1d588a0..677529a 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -30,6 +30,7 @@ ComponentHostsArgs, ComponentIdArgs, ControlScriptExecutionArgs, + CreateCyclicSubArgs, CreateExecutionArgs, CreateTriggerArgs, DependenciesArgs, @@ -49,6 +50,7 @@ FreezeFrameSnapshot, FunctionIdArgs, GetConfigurationArgs, + GetCyclicSubArgs, GetLockArgs, GetLogConfigurationArgs, GetOperationArgs, @@ -56,6 +58,7 @@ GetScriptExecutionArgs, GetTriggerArgs, ListConfigurationsArgs, + ListCyclicSubsArgs, ListExecutionsArgs, ListLocksArgs, ListLogsArgs, @@ -70,6 +73,7 @@ SubcomponentsArgs, SystemFaultSnapshotsArgs, ToolResult, + UpdateCyclicSubArgs, UpdateExecutionArgs, UpdateTriggerArgs, UploadScriptArgs, @@ -669,6 +673,12 @@ async def download_rosbags_for_fault( "sovd_get_lock": "sovd_get_lock", "sovd_extend_lock": "sovd_extend_lock", "sovd_release_lock": "sovd_release_lock", + # Cyclic Subscriptions + "sovd_create_cyclic_sub": "sovd_create_cyclic_sub", + "sovd_list_cyclic_subs": "sovd_list_cyclic_subs", + "sovd_get_cyclic_sub": "sovd_get_cyclic_sub", + "sovd_update_cyclic_sub": "sovd_update_cyclic_sub", + "sovd_delete_cyclic_sub": "sovd_delete_cyclic_sub", } @@ -1982,6 +1992,122 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "lock_id"], }, ), + # ==================== Cyclic Subscriptions ==================== + Tool( + name="sovd_create_cyclic_sub", + description="Create a cyclic data subscription for an entity. Subscribes to periodic data updates.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "sub_config": { + "type": "object", + "description": "Subscription configuration (e.g., {'resource': '/data/temperature', 'period': 1000})", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "sub_config"], + }, + ), + Tool( + name="sovd_list_cyclic_subs", + description="List all cyclic subscriptions for an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_get_cyclic_sub", + description="Get details of a specific cyclic subscription.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "subscription_id": { + "type": "string", + "description": "The subscription identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "subscription_id"], + }, + ), + Tool( + name="sovd_update_cyclic_sub", + description="Update a cyclic subscription's configuration.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "subscription_id": { + "type": "string", + "description": "The subscription identifier", + }, + "sub_config": { + "type": "object", + "description": "Updated subscription configuration", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "subscription_id", "sub_config"], + }, + ), + Tool( + name="sovd_delete_cyclic_sub", + description="Delete a cyclic subscription.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "subscription_id": { + "type": "string", + "description": "The subscription identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id", "subscription_id"], + }, + ), ] # Append plugin tools if plugins: @@ -2437,6 +2563,41 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.release_lock(args.entity_id, args.lock_id, args.entity_type) return format_json_response(result) + # ==================== Cyclic Subscriptions ==================== + + elif normalized_name == "sovd_create_cyclic_sub": + args = CreateCyclicSubArgs(**arguments) + result = await client.create_cyclic_subscription( + args.entity_id, args.sub_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_list_cyclic_subs": + args = ListCyclicSubsArgs(**arguments) + result = await client.list_cyclic_subscriptions(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_get_cyclic_sub": + args = GetCyclicSubArgs(**arguments) + result = await client.get_cyclic_subscription( + args.entity_id, args.subscription_id, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_update_cyclic_sub": + args = UpdateCyclicSubArgs(**arguments) + result = await client.update_cyclic_subscription( + args.entity_id, args.subscription_id, args.sub_config, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_delete_cyclic_sub": + args = GetCyclicSubArgs(**arguments) + result = await client.delete_cyclic_subscription( + args.entity_id, args.subscription_id, args.entity_type + ) + 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 7eed285..67e3552 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -964,6 +964,59 @@ class ExtendLockArgs(BaseModel): ) +# ==================== Cyclic Subscriptions Argument Models ==================== + + +class CreateCyclicSubArgs(BaseModel): + """Arguments for sovd_create_cyclic_sub tool.""" + + entity_id: str = Field(..., description="The entity identifier") + sub_config: dict[str, Any] = Field( + ..., + description=( + "Subscription configuration" + " (e.g., {'resource': '/data/temperature', 'period': 1000})" + ), + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', or 'functions'", + ) + + +class ListCyclicSubsArgs(BaseModel): + """Arguments for sovd_list_cyclic_subs tool.""" + + entity_id: str = Field(..., description="The entity identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', or 'functions'", + ) + + +class GetCyclicSubArgs(BaseModel): + """Arguments for sovd_get_cyclic_sub and sovd_delete_cyclic_sub tools.""" + + entity_id: str = Field(..., description="The entity identifier") + subscription_id: str = Field(..., description="The subscription identifier") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', or 'functions'", + ) + + +class UpdateCyclicSubArgs(BaseModel): + """Arguments for sovd_update_cyclic_sub tool.""" + + entity_id: str = Field(..., description="The entity identifier") + subscription_id: str = Field(..., description="The subscription identifier") + sub_config: dict[str, Any] = Field(..., description="Updated subscription configuration") + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', or 'functions'", + ) + + class ToolResult(BaseModel): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 6e3db0c..757cdab 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -342,3 +342,75 @@ async def test_release_lock(self, client: SovdClient) -> None: result = await client.release_lock("motor", "lock-1") assert result == {} await client.close() + + +class TestSubscriptionsTools: + """Tests for cyclic subscription management tools. + + Cyclic subscriptions are supported on components, apps, and functions (not areas). + """ + + # CyclicSubscription model requires: id, event_source, interval, observed_resource, protocol + SUBSCRIPTION_RESPONSE = { + "id": "sub-1", + "event_source": "/events", + "interval": "fast", + "observed_resource": "/data/temperature", + "protocol": "sse", + } + + @respx.mock + async def test_create_cyclic_subscription(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions").mock( + return_value=httpx.Response(201, json=self.SUBSCRIPTION_RESPONSE) + ) + result = await client.create_cyclic_subscription( + "motor", + {"resource": "/data/temperature", "interval": "fast", "duration": 60}, + ) + assert result["id"] == "sub-1" + assert result["observed_resource"] == "/data/temperature" + await client.close() + + @respx.mock + async def test_list_cyclic_subscriptions(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions").mock( + return_value=httpx.Response(200, json={"items": [self.SUBSCRIPTION_RESPONSE]}) + ) + result = await client.list_cyclic_subscriptions("motor") + assert len(result) == 1 + assert result[0]["id"] == "sub-1" + await client.close() + + @respx.mock + async def test_get_cyclic_subscription(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions/sub-1").mock( + return_value=httpx.Response(200, json=self.SUBSCRIPTION_RESPONSE) + ) + result = await client.get_cyclic_subscription("motor", "sub-1") + assert result["id"] == "sub-1" + assert result["protocol"] == "sse" + await client.close() + + @respx.mock + async def test_update_cyclic_subscription(self, client: SovdClient) -> None: + updated = {**self.SUBSCRIPTION_RESPONSE, "interval": "slow"} + respx.put("http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions/sub-1").mock( + return_value=httpx.Response(200, json=updated) + ) + result = await client.update_cyclic_subscription( + "motor", + "sub-1", + {**self.SUBSCRIPTION_RESPONSE, "interval": "slow"}, + ) + assert result["interval"] == "slow" + await client.close() + + @respx.mock + async def test_delete_cyclic_subscription(self, client: SovdClient) -> None: + respx.delete( + "http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions/sub-1" + ).mock(return_value=httpx.Response(204)) + result = await client.delete_cyclic_subscription("motor", "sub-1") + assert result == {} + await client.close() From dc05f2bf01f504612b427478796aa64fb06e5d21 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 09:34:03 +0200 Subject: [PATCH 06/14] feat: add software updates MCP tools (register, prepare, execute, automate, delete) --- src/ros2_medkit_mcp/client.py | 76 ++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 187 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 63 +++++++++++ tests/test_new_tools.py | 107 +++++++++++++++++++ 4 files changed, 433 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 7a6c96e..302e14d 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -31,6 +31,7 @@ server, subscriptions, triggers, + updates, ) from ros2_medkit_mcp.config import Settings @@ -1240,6 +1241,81 @@ async def delete_cyclic_subscription( except (ValueError, KeyError) as e: raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Software Updates ==================== + + async def list_updates(self) -> list[dict[str, Any]]: + return _extract_items(await self._call(updates.list_updates.asyncio)) + + async def register_update(self, update_config: dict[str, Any]) -> dict[str, Any]: + return await self._call(updates.register_update.asyncio, body=update_config) + + async def get_update(self, update_id: str) -> dict[str, Any]: + return await self._call(updates.get_update.asyncio, update_id=update_id) + + 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 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 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 _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, Any]: + """Call a generated update action function that returns 202 with None body. + + The generated client returns None for 202 Accepted, which MedkitClient.call() + treats as an error. Call the function directly and treat None as success. + """ + if "body" in kwargs and isinstance(kwargs["body"], dict): + kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"]) + client = await self._ensure_client() + try: + result = await api_func(client=client.http, **kwargs) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + + async def delete_update(self, update_id: str) -> dict[str, Any]: + # delete_update returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await updates.delete_update.asyncio( + client=client.http, + update_id=update_id, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + @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 677529a..80cd165 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -20,6 +20,7 @@ AreaComponentsArgs, AreaContainsArgs, AreaIdArgs, + AutomateUpdateArgs, BulkDataCategoriesArgs, BulkDataDownloadArgs, BulkDataDownloadForFaultArgs, @@ -40,6 +41,7 @@ EntityTopicDataArgs, EnvironmentData, ExecuteScriptArgs, + ExecuteUpdateArgs, ExecutionArgs, ExtendedDataRecords, ExtendLockArgs, @@ -57,6 +59,8 @@ GetScriptArgs, GetScriptExecutionArgs, GetTriggerArgs, + GetUpdateArgs, + GetUpdateStatusArgs, ListConfigurationsArgs, ListCyclicSubsArgs, ListExecutionsArgs, @@ -65,7 +69,10 @@ ListOperationsArgs, ListScriptsArgs, ListTriggersArgs, + ListUpdatesArgs, + PrepareUpdateArgs, PublishTopicArgs, + RegisterUpdateArgs, RosbagSnapshot, SetConfigurationArgs, SetLogConfigurationArgs, @@ -679,6 +686,15 @@ async def download_rosbags_for_fault( "sovd_get_cyclic_sub": "sovd_get_cyclic_sub", "sovd_update_cyclic_sub": "sovd_update_cyclic_sub", "sovd_delete_cyclic_sub": "sovd_delete_cyclic_sub", + # Software Updates + "sovd_list_updates": "sovd_list_updates", + "sovd_register_update": "sovd_register_update", + "sovd_get_update": "sovd_get_update", + "sovd_get_update_status": "sovd_get_update_status", + "sovd_prepare_update": "sovd_prepare_update", + "sovd_execute_update": "sovd_execute_update", + "sovd_automate_update": "sovd_automate_update", + "sovd_delete_update": "sovd_delete_update", } @@ -2108,6 +2124,135 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "subscription_id"], }, ), + # ==================== Software Updates ==================== + Tool( + name="sovd_list_updates", + description="List all registered software updates.", + inputSchema={ + "type": "object", + "properties": {}, + }, + ), + Tool( + name="sovd_register_update", + description="Register a new software update package.", + inputSchema={ + "type": "object", + "properties": { + "update_config": { + "type": "object", + "description": ( + "Update package configuration" + " (e.g., {'name': 'firmware-v2', 'version': '2.0.0'," + " 'uri': 'https://...'})" + ), + }, + }, + "required": ["update_config"], + }, + ), + Tool( + name="sovd_get_update", + description="Get details of a registered update.", + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + }, + "required": ["update_id"], + }, + ), + Tool( + name="sovd_get_update_status", + description=( + "Get the current status of an update" + " (pending, preparing, ready, executing, complete, failed)." + ), + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + }, + "required": ["update_id"], + }, + ), + Tool( + name="sovd_prepare_update", + description="Prepare an update for execution (download, verify, stage).", + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + "config": { + "type": "object", + "description": "Preparation configuration (e.g., {'verify_checksum': true})", + }, + }, + "required": ["update_id", "config"], + }, + ), + Tool( + name="sovd_execute_update", + description="Execute a prepared update.", + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + "config": { + "type": "object", + "description": "Execution configuration (e.g., {'reboot_after': true})", + }, + }, + "required": ["update_id", "config"], + }, + ), + Tool( + name="sovd_automate_update", + description="Run full automated update flow (prepare + execute).", + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + "config": { + "type": "object", + "description": ( + "Automation configuration" + " (e.g., {'verify_checksum': true, 'reboot_after': true})" + ), + }, + }, + "required": ["update_id", "config"], + }, + ), + Tool( + name="sovd_delete_update", + description="Delete a registered update.", + inputSchema={ + "type": "object", + "properties": { + "update_id": { + "type": "string", + "description": "The update identifier", + }, + }, + "required": ["update_id"], + }, + ), ] # Append plugin tools if plugins: @@ -2598,6 +2743,48 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) + # ==================== Software Updates ==================== + + elif normalized_name == "sovd_list_updates": + ListUpdatesArgs(**arguments) + result = await client.list_updates() + return format_json_response(result) + + elif normalized_name == "sovd_register_update": + args = RegisterUpdateArgs(**arguments) + result = await client.register_update(args.update_config) + return format_json_response(result) + + elif normalized_name == "sovd_get_update": + args = GetUpdateArgs(**arguments) + result = await client.get_update(args.update_id) + return format_json_response(result) + + elif normalized_name == "sovd_get_update_status": + args = GetUpdateStatusArgs(**arguments) + result = await client.get_update_status(args.update_id) + return format_json_response(result) + + elif normalized_name == "sovd_prepare_update": + args = PrepareUpdateArgs(**arguments) + result = await client.prepare_update(args.update_id, args.config) + return format_json_response(result) + + elif normalized_name == "sovd_execute_update": + args = ExecuteUpdateArgs(**arguments) + result = await client.execute_update(args.update_id, args.config) + return format_json_response(result) + + elif normalized_name == "sovd_automate_update": + args = AutomateUpdateArgs(**arguments) + result = await client.automate_update(args.update_id, args.config) + return format_json_response(result) + + elif normalized_name == "sovd_delete_update": + args = GetUpdateArgs(**arguments) + result = await client.delete_update(args.update_id) + 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 67e3552..cb7a924 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -1017,6 +1017,69 @@ class UpdateCyclicSubArgs(BaseModel): ) +# ==================== Software Updates Argument Models ==================== + + +class ListUpdatesArgs(BaseModel): + """Arguments for sovd_list_updates tool.""" + + pass + + +class RegisterUpdateArgs(BaseModel): + """Arguments for sovd_register_update tool.""" + + update_config: dict[str, Any] = Field( + ..., + description=( + "Update package configuration" + " (e.g., {'name': 'firmware-v2', 'version': '2.0.0', 'uri': 'https://...'})" + ), + ) + + +class GetUpdateArgs(BaseModel): + """Arguments for sovd_get_update and sovd_delete_update tools.""" + + update_id: str = Field(..., description="The update identifier") + + +class GetUpdateStatusArgs(BaseModel): + """Arguments for sovd_get_update_status tool.""" + + update_id: str = Field(..., description="The update identifier") + + +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): """Standard result wrapper for tool responses.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 757cdab..fa4b73c 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -414,3 +414,110 @@ async def test_delete_cyclic_subscription(self, client: SovdClient) -> None: result = await client.delete_cyclic_subscription("motor", "sub-1") assert result == {} await client.close() + + +class TestUpdatesTools: + """Tests for software update management tools. + + Updates are global endpoints (no entity type dispatch). + URLs are /updates, /updates/{update_id}, etc. + """ + + UPDATE_RESPONSE = { + "id": "upd-1", + "name": "firmware-v2", + "version": "2.0.0", + "status": "pending", + } + + UPDATE_STATUS_RESPONSE = { + "status": "inProgress", + "progress": 50, + } + + @respx.mock + async def test_list_updates(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/updates").mock( + return_value=httpx.Response( + 200, + json={"items": [self.UPDATE_RESPONSE]}, + ) + ) + result = await client.list_updates() + assert len(result) == 1 + assert result[0]["id"] == "upd-1" + assert result[0]["name"] == "firmware-v2" + await client.close() + + @respx.mock + async def test_register_update(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/updates").mock( + return_value=httpx.Response(201, json=self.UPDATE_RESPONSE) + ) + result = await client.register_update( + {"name": "firmware-v2", "version": "2.0.0", "uri": "https://example.com/fw.bin"} + ) + assert result["id"] == "upd-1" + assert result["status"] == "pending" + await client.close() + + @respx.mock + async def test_get_update(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/updates/upd-1").mock( + return_value=httpx.Response(200, json=self.UPDATE_RESPONSE) + ) + result = await client.get_update("upd-1") + assert result["id"] == "upd-1" + assert result["version"] == "2.0.0" + await client.close() + + @respx.mock + async def test_get_update_status(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/updates/upd-1/status").mock( + return_value=httpx.Response(200, json=self.UPDATE_STATUS_RESPONSE) + ) + result = await client.get_update_status("upd-1") + assert result["status"] == "inProgress" + assert result["progress"] == 50 + await client.close() + + @respx.mock + async def test_delete_update(self, client: SovdClient) -> None: + respx.delete("http://test-sovd:8080/api/v1/updates/upd-1").mock( + return_value=httpx.Response(204) + ) + result = await client.delete_update("upd-1") + assert result == {} + await client.close() + + @respx.mock + async def test_prepare_update(self, client: SovdClient) -> None: + # prepare_update returns 202 Accepted; generated client returns None for 202 + 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}) + assert result == {} + await client.close() + + @respx.mock + async def test_execute_update(self, client: SovdClient) -> None: + # execute_update returns 202 Accepted; generated client returns None for 202 + 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}) + assert result == {} + await client.close() + + @respx.mock + async def test_automate_update(self, client: SovdClient) -> None: + # automate_update returns 202 Accepted; generated client returns None for 202 + 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} + ) + assert result == {} + await client.close() From 9b63122c73d202b8f0b8c777f90a9299f1022b0b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 09:43:25 +0200 Subject: [PATCH 07/14] feat: add data discovery and bulk data upload/delete MCP tools Add 4 new tools: - sovd_data_categories: list data categories for an entity - sovd_data_groups: list data groups for an entity - sovd_bulkdata_upload: upload a file to entity bulk data storage - sovd_bulkdata_delete: delete a bulk data item (204 handling) --- src/ros2_medkit_mcp/client.py | 108 ++++++++++++++++++++++++++ src/ros2_medkit_mcp/mcp_app.py | 135 +++++++++++++++++++++++++++++++++ src/ros2_medkit_mcp/models.py | 72 ++++++++++++++++++ tests/test_new_tools.py | 101 ++++++++++++++++++++++++ 4 files changed, 416 insertions(+) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 302e14d..1f99776 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -242,6 +242,22 @@ def _validate_relative_uri(uri: str) -> None: "functions": configuration.delete_all_function_configurations, }, }, + "data_categories": { + "list": { + "components": data.list_component_data_categories, + "apps": data.list_app_data_categories, + "areas": data.list_area_data_categories, + "functions": data.list_function_data_categories, + }, + }, + "data_groups": { + "list": { + "components": data.list_component_data_groups, + "apps": data.list_app_data_groups, + "areas": data.list_area_data_groups, + "functions": data.list_function_data_groups, + }, + }, "bulk_data": { "list_categories": { "components": bulk_data.list_component_bulk_data_categories, @@ -255,6 +271,14 @@ def _validate_relative_uri(uri: str) -> None: "areas": bulk_data.list_area_bulk_data_descriptors, "functions": bulk_data.list_function_bulk_data_descriptors, }, + "delete": { + "components": bulk_data.delete_component_bulk_data, + "apps": bulk_data.delete_app_bulk_data, + }, + "upload": { + "components": bulk_data.upload_component_bulk_data, + "apps": bulk_data.upload_app_bulk_data, + }, }, "logs": { "list": { @@ -819,6 +843,20 @@ async def delete_all_configurations( fn = _entity_func("configurations", "delete_all", entity_type) return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}) + # ==================== Data Discovery ==================== + + async def list_data_categories( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("data_categories", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + + async def list_data_groups( + self, entity_id: str, entity_type: str = "components" + ) -> list[dict[str, Any]]: + fn = _entity_func("data_groups", "list", entity_type) + return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})) + # ==================== Bulk Data ==================== async def list_bulk_data_categories( @@ -882,6 +920,76 @@ async def download_bulk_data(self, bulk_data_uri: str) -> tuple[bytes, str | Non return response.content, _extract_filename(response.headers.get("Content-Disposition", "")) + async def delete_bulk_data_item( + self, + entity_id: str, + category: str, + item_id: str, + entity_type: str = "apps", + ) -> dict[str, Any]: + fn = _entity_func("bulk_data", "delete", entity_type) + # delete returns 204 No Content on success. + # The generated client returns None for 204, which MedkitClient.call() + # treats as an error. Call the function directly and treat None as success. + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{ + _entity_id_kwarg(entity_type): entity_id, + "category_id": category, + "file_id": item_id, + }, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + + async def upload_bulk_data( + self, + entity_id: str, + category: str, + file_content: bytes, + 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", + ) + client = await self._ensure_client() + try: + result = await fn( + client=client.http, + **{ + _entity_id_kwarg(entity_type): entity_id, + "category_id": category, + "body": file_obj, + }, + ) + if result is None: + return {} + return _to_dict(result) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + # ==================== Logs ==================== async def list_logs( diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 80cd165..cf24a0e 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -22,11 +22,13 @@ AreaIdArgs, AutomateUpdateArgs, BulkDataCategoriesArgs, + BulkDataDeleteArgs, BulkDataDownloadArgs, BulkDataDownloadForFaultArgs, BulkDataInfoArgs, BulkDataItem, BulkDataListArgs, + BulkDataUploadArgs, ClearAllFaultsArgs, ComponentHostsArgs, ComponentIdArgs, @@ -34,6 +36,8 @@ CreateCyclicSubArgs, CreateExecutionArgs, CreateTriggerArgs, + DataCategoriesArgs, + DataGroupsArgs, DependenciesArgs, EntitiesListArgs, EntityDataArgs, @@ -651,12 +655,17 @@ async def download_rosbags_for_fault( "sovd_clear_all_faults": "sovd_clear_all_faults", "sovd_fault_snapshots": "sovd_fault_snapshots", "sovd_system_fault_snapshots": "sovd_system_fault_snapshots", + # Data discovery + "sovd_data_categories": "sovd_data_categories", + "sovd_data_groups": "sovd_data_groups", # Bulk data "sovd_bulkdata_categories": "sovd_bulkdata_categories", "sovd_bulkdata_list": "sovd_bulkdata_list", "sovd_bulkdata_info": "sovd_bulkdata_info", "sovd_bulkdata_download": "sovd_bulkdata_download", "sovd_bulkdata_download_for_fault": "sovd_bulkdata_download_for_fault", + "sovd_bulkdata_upload": "sovd_bulkdata_upload", + "sovd_bulkdata_delete": "sovd_bulkdata_delete", # Logs "sovd_list_logs": "sovd_list_logs", "sovd_get_log_configuration": "sovd_get_log_configuration", @@ -1459,6 +1468,45 @@ async def list_tools() -> list[Tool]: "required": ["entity_id"], }, ), + # ==================== Data Discovery ==================== + Tool( + name="sovd_data_categories", + description="List data categories for an entity (e.g., topics, parameters).", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), + Tool( + name="sovd_data_groups", + description="List data groups for an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "default": "components", + }, + }, + "required": ["entity_id"], + }, + ), # ==================== Bulk Data ==================== Tool( name="sovd_bulkdata_categories", @@ -1563,6 +1611,64 @@ async def list_tools() -> list[Tool]: "required": ["entity_id", "fault_code"], }, ), + Tool( + name="sovd_bulkdata_upload", + description="Upload a file to an entity's bulk data storage.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "category": { + "type": "string", + "description": "Category name (e.g., 'rosbags')", + }, + "file_content": { + "type": "string", + "description": "Base64-encoded file content to upload", + }, + "filename": { + "type": "string", + "description": "Filename for the uploaded file", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "apps", + }, + }, + "required": ["entity_id", "category", "file_content", "filename"], + }, + ), + Tool( + name="sovd_bulkdata_delete", + description="Delete a bulk data item.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "category": { + "type": "string", + "description": "Category name (e.g., 'rosbags')", + }, + "item_id": { + "type": "string", + "description": "The bulk-data item identifier", + }, + "entity_type": { + "type": "string", + "description": "Entity type: 'components' or 'apps'", + "default": "apps", + }, + }, + "required": ["entity_id", "category", "item_id"], + }, + ), # ==================== Logs ==================== Tool( name="sovd_list_logs", @@ -2551,6 +2657,18 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.delete_all_configurations(args.entity_id, args.entity_type) return format_json_response(result) + # ==================== Data Discovery ==================== + + elif normalized_name == "sovd_data_categories": + args = DataCategoriesArgs(**arguments) + result = await client.list_data_categories(args.entity_id, args.entity_type) + return format_json_response(result) + + elif normalized_name == "sovd_data_groups": + args = DataGroupsArgs(**arguments) + result = await client.list_data_groups(args.entity_id, args.entity_type) + return format_json_response(result) + # ==================== Bulk Data ==================== elif normalized_name == "sovd_bulkdata_categories": @@ -2581,6 +2699,23 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: client, args.entity_id, args.fault_code, args.entity_type, args.output_dir ) + elif normalized_name == "sovd_bulkdata_upload": + args = BulkDataUploadArgs(**arguments) + import base64 + + file_bytes = base64.b64decode(args.file_content) + result = await client.upload_bulk_data( + args.entity_id, args.category, file_bytes, args.filename, args.entity_type + ) + return format_json_response(result) + + elif normalized_name == "sovd_bulkdata_delete": + args = BulkDataDeleteArgs(**arguments) + result = await client.delete_bulk_data_item( + args.entity_id, args.category, args.item_id, args.entity_type + ) + return format_json_response(result) + # ==================== Logs ==================== elif normalized_name == "sovd_list_logs": diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index cb7a924..ee952c0 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -667,6 +667,78 @@ class BulkDataListResponse(BaseModel): # ==================== Bulk Data Argument Models ==================== +class DataCategoriesArgs(BaseModel): + """Arguments for listing data categories.""" + + entity_id: str = Field( + ..., + description="The entity identifier", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class DataGroupsArgs(BaseModel): + """Arguments for listing data groups.""" + + entity_id: str = Field( + ..., + description="The entity identifier", + ) + entity_type: str = Field( + default="components", + description="Entity type: 'components', 'apps', 'areas', or 'functions'", + ) + + +class BulkDataUploadArgs(BaseModel): + """Arguments for uploading a bulk-data file.""" + + entity_id: str = Field( + ..., + description="The entity identifier", + ) + category: str = Field( + ..., + description="Category name (e.g., 'rosbags')", + ) + file_content: str = Field( + ..., + description="Base64-encoded file content to upload", + ) + filename: str = Field( + ..., + description="Filename for the uploaded file", + ) + entity_type: str = Field( + default="apps", + description="Entity type: 'components' or 'apps'", + ) + + +class BulkDataDeleteArgs(BaseModel): + """Arguments for deleting a bulk-data item.""" + + entity_id: str = Field( + ..., + description="The entity identifier", + ) + category: str = Field( + ..., + description="Category name (e.g., 'rosbags')", + ) + item_id: str = Field( + ..., + description="The bulk-data item identifier", + ) + entity_type: str = Field( + default="apps", + description="Entity type: 'components' or 'apps'", + ) + + class BulkDataCategoriesArgs(BaseModel): """Arguments for listing bulk-data categories.""" diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index fa4b73c..c2d7063 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -521,3 +521,104 @@ async def test_automate_update(self, client: SovdClient) -> None: ) assert result == {} await client.close() + + +class TestDataDiscoveryTools: + """Tests for data discovery tools (categories and groups).""" + + @respx.mock + async def test_list_data_categories(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/data-categories").mock( + return_value=httpx.Response( + 200, + json={"items": ["topics", "parameters"]}, + ) + ) + result = await client.list_data_categories("motor") + assert result == ["topics", "parameters"] + await client.close() + + @respx.mock + async def test_list_data_categories_apps(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/my_node/data-categories").mock( + return_value=httpx.Response(200, json={"items": ["topics"]}) + ) + result = await client.list_data_categories("my_node", "apps") + assert result == ["topics"] + await client.close() + + @respx.mock + async def test_list_data_groups(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/data-groups").mock( + return_value=httpx.Response( + 200, + json={"items": [{"id": "sensor_data", "name": "Sensor Data"}]}, + ) + ) + result = await client.list_data_groups("motor") + assert len(result) == 1 + assert result[0]["id"] == "sensor_data" + await client.close() + + @respx.mock + async def test_list_data_groups_apps(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/apps/my_node/data-groups").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + result = await client.list_data_groups("my_node", "apps") + assert result == [] + await client.close() + + +class TestBulkDataUploadDeleteTools: + """Tests for bulk data upload and delete tools.""" + + @respx.mock + async def test_delete_bulk_data_item(self, client: SovdClient) -> None: + respx.delete("http://test-sovd:8080/api/v1/apps/motor/bulk-data/rosbags/item-123").mock( + return_value=httpx.Response(204) + ) + result = await client.delete_bulk_data_item("motor", "rosbags", "item-123") + assert result == {} + await client.close() + + @respx.mock + async def test_delete_bulk_data_item_components(self, client: SovdClient) -> None: + respx.delete( + "http://test-sovd:8080/api/v1/components/motor/bulk-data/rosbags/item-456" + ).mock(return_value=httpx.Response(204)) + result = await client.delete_bulk_data_item("motor", "rosbags", "item-456", "components") + assert result == {} + await client.close() + + @respx.mock + async def test_upload_bulk_data(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/apps/motor/bulk-data/rosbags").mock( + return_value=httpx.Response( + 201, + json={ + "id": "uploaded-1", + "name": "test.mcap", + "mimetype": "application/x-mcap", + "size": 42, + }, + ) + ) + result = await client.upload_bulk_data("motor", "rosbags", b"fake-content", "test.mcap") + assert result["id"] == "uploaded-1" + assert result["name"] == "test.mcap" + await client.close() + + @respx.mock + async def test_upload_bulk_data_components(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/bulk-data/rosbags").mock( + return_value=httpx.Response( + 201, + json={"id": "uploaded-2", "name": "data.bin"}, + ) + ) + result = await client.upload_bulk_data( + "motor", "rosbags", b"binary-data", "data.bin", "components" + ) + assert result["id"] == "uploaded-2" + await client.close() From 09e95ff341dc91d5c017fd1545ff0349ff952195 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 10:06:31 +0200 Subject: [PATCH 08/14] fix: address review findings - delete_script registration, input validation, warnings - Register sovd_delete_script tool (alias, Tool definition, dispatcher) - Add max_length constraints to UploadScriptArgs and BulkDataUploadArgs - Add WARNING to sovd_execute_update and sovd_automate_update descriptions - Add enum constraints to entity_type in all tool inputSchemas - Wrap base64.b64decode in try/except for sovd_bulkdata_upload - Move base64 import to module level - Refactor client.py with _call_void for void-returning endpoints --- src/ros2_medkit_mcp/client.py | 257 +++++++++------------------------ src/ros2_medkit_mcp/mcp_app.py | 197 ++++++++++++++++++------- src/ros2_medkit_mcp/models.py | 4 +- 3 files changed, 213 insertions(+), 245 deletions(-) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 1f99776..bf4673f 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -508,6 +508,34 @@ async def _call(self, api_func: Any, **kwargs: Any) -> Any: except (ValueError, KeyError) as e: 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. + """ + if "body" in kwargs and isinstance(kwargs["body"], dict): + kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"]) + client = await self._ensure_client() + try: + result = await api_func(client=client.http, **kwargs) + if result 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) + except httpx.TimeoutException as e: + raise SovdClientError(message=f"Request timed out: {e}") from e + except httpx.RequestError as e: + raise SovdClientError(message=f"Request failed: {e}") from e + except (ValueError, KeyError) as e: + raise SovdClientError(message=f"Failed to parse response: {e}") from e + async def _raw_request(self, method: str, path: str) -> Any: """Make a raw HTTP request for endpoints not in the generated client (fault snapshots). Path segments must be pre-encoded by the caller.""" @@ -928,28 +956,14 @@ async def delete_bulk_data_item( entity_type: str = "apps", ) -> dict[str, Any]: fn = _entity_func("bulk_data", "delete", entity_type) - # delete returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{ - _entity_id_kwarg(entity_type): entity_id, - "category_id": category, - "file_id": item_id, - }, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "category_id": category, + "file_id": item_id, + }, + ) async def upload_bulk_data( self, @@ -970,25 +984,14 @@ async def upload_bulk_data( file_name=filename, mime_type="application/octet-stream", ) - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{ - _entity_id_kwarg(entity_type): entity_id, - "category_id": category, - "body": file_obj, - }, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "category_id": category, + "body": file_obj, + }, + ) # ==================== Logs ==================== @@ -1008,26 +1011,9 @@ async def set_log_configuration( self, entity_id: str, config: dict[str, Any], entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("logs", "set_config", entity_type) - # set_log_configuration returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - if isinstance(config, dict): - config = _wrap_body_dict(fn, config) - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{_entity_id_kwarg(entity_type): entity_id, "body": config}, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, **{_entity_id_kwarg(entity_type): entity_id, "body": config} + ) # ==================== Triggers ==================== @@ -1074,24 +1060,9 @@ async def delete_trigger( self, entity_id: str, trigger_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("triggers", "delete", entity_type) - # delete_trigger returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{_entity_id_kwarg(entity_type): entity_id, "trigger_id": trigger_id}, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, **{_entity_id_kwarg(entity_type): entity_id, "trigger_id": trigger_id} + ) # ==================== Scripts ==================== @@ -1124,21 +1095,9 @@ async def upload_script( file_name="script.py", mime_type="application/octet-stream", ) - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{_entity_id_kwarg(entity_type): entity_id, "body": file_obj}, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, **{_entity_id_kwarg(entity_type): entity_id, "body": file_obj} + ) async def execute_script( self, @@ -1195,24 +1154,9 @@ async def delete_script( self, entity_id: str, script_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("scripts", "delete", entity_type) - # delete_script returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{_entity_id_kwarg(entity_type): entity_id, "script_id": script_id}, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, **{_entity_id_kwarg(entity_type): entity_id, "script_id": script_id} + ) # ==================== Locking ==================== @@ -1259,24 +1203,9 @@ async def release_lock( self, entity_id: str, lock_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("locking", "release", entity_type) - # release_lock returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{_entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id}, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, **{_entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id} + ) # ==================== Cyclic Subscriptions ==================== @@ -1327,27 +1256,13 @@ async def delete_cyclic_subscription( self, entity_id: str, subscription_id: str, entity_type: str = "components" ) -> dict[str, Any]: fn = _entity_func("subscriptions", "delete", entity_type) - # delete_subscription returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await fn( - client=client.http, - **{ - _entity_id_kwarg(entity_type): entity_id, - "subscription_id": subscription_id, - }, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void( + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "subscription_id": subscription_id, + }, + ) # ==================== Software Updates ==================== @@ -1384,45 +1299,11 @@ async def automate_update(self, update_id: str, config: dict[str, Any]) -> dict[ ) 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. - - The generated client returns None for 202 Accepted, which MedkitClient.call() - treats as an error. Call the function directly and treat None as success. - """ - if "body" in kwargs and isinstance(kwargs["body"], dict): - kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"]) - client = await self._ensure_client() - try: - result = await api_func(client=client.http, **kwargs) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + """Call a generated update action function that returns 202 with None body.""" + return await self._call_void(api_func, **kwargs) async def delete_update(self, update_id: str) -> dict[str, Any]: - # delete_update returns 204 No Content on success. - # The generated client returns None for 204, which MedkitClient.call() - # treats as an error. Call the function directly and treat None as success. - client = await self._ensure_client() - try: - result = await updates.delete_update.asyncio( - client=client.http, - update_id=update_id, - ) - if result is None: - return {} - return _to_dict(result) - except httpx.TimeoutException as e: - raise SovdClientError(message=f"Request timed out: {e}") from e - except httpx.RequestError as e: - raise SovdClientError(message=f"Request failed: {e}") from e - except (ValueError, KeyError) as e: - raise SovdClientError(message=f"Failed to parse response: {e}") from e + return await self._call_void(updates.delete_update.asyncio, update_id=update_id) @asynccontextmanager diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index cf24a0e..7ad1571 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -4,6 +4,7 @@ intended to be reused by both stdio and HTTP transport entrypoints. """ +import base64 import json import logging from pathlib import Path @@ -683,6 +684,7 @@ async def download_rosbags_for_fault( "sovd_execute_script": "sovd_execute_script", "sovd_get_script_execution": "sovd_get_script_execution", "sovd_control_script_execution": "sovd_control_script_execution", + "sovd_delete_script": "sovd_delete_script", # Locking "sovd_acquire_lock": "sovd_acquire_lock", "sovd_list_locks": "sovd_list_locks", @@ -830,7 +832,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -853,7 +856,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -876,7 +880,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -904,7 +909,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -927,7 +933,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1122,7 +1129,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1145,7 +1153,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1172,7 +1181,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1192,7 +1202,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1215,7 +1226,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1242,7 +1254,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1265,7 +1278,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1292,7 +1306,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1323,7 +1338,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1350,7 +1366,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1370,7 +1387,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1393,7 +1411,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1419,7 +1438,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1442,7 +1462,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1461,7 +1482,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1481,7 +1503,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1500,7 +1523,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1520,7 +1544,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "apps", }, }, @@ -1543,7 +1568,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "apps", }, }, @@ -1599,7 +1625,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "apps", }, "output_dir": { @@ -1635,7 +1662,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "apps", }, }, @@ -1662,7 +1690,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "apps", }, }, @@ -1682,7 +1711,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1701,7 +1731,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1724,7 +1755,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1744,7 +1776,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1767,7 +1800,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1790,7 +1824,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1817,7 +1852,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1840,7 +1876,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', 'areas', or 'functions'", + "enum": ["components", "apps", "areas", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -1860,7 +1897,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -1883,7 +1921,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -1906,7 +1945,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -1933,7 +1973,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -1960,7 +2001,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -1991,13 +2033,38 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, "required": ["entity_id", "script_id", "execution_id", "action"], }, ), + Tool( + name="sovd_delete_script", + description="Delete a script from an entity.", + inputSchema={ + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "The entity identifier", + }, + "script_id": { + "type": "string", + "description": "The script identifier", + }, + "entity_type": { + "type": "string", + "enum": ["components", "apps"], + "description": "Entity type", + "default": "components", + }, + }, + "required": ["entity_id", "script_id"], + }, + ), # ==================== Locking ==================== Tool( name="sovd_acquire_lock", @@ -2015,7 +2082,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -2034,7 +2102,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -2057,7 +2126,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -2084,7 +2154,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -2107,7 +2178,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components' or 'apps'", + "enum": ["components", "apps"], + "description": "Entity type", "default": "components", }, }, @@ -2131,7 +2203,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', or 'functions'", + "enum": ["components", "apps", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -2150,7 +2223,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', or 'functions'", + "enum": ["components", "apps", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -2173,7 +2247,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', or 'functions'", + "enum": ["components", "apps", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -2200,7 +2275,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', or 'functions'", + "enum": ["components", "apps", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -2223,7 +2299,8 @@ async def list_tools() -> list[Tool]: }, "entity_type": { "type": "string", - "description": "Entity type: 'components', 'apps', or 'functions'", + "enum": ["components", "apps", "functions"], + "description": "Entity type", "default": "components", }, }, @@ -2308,7 +2385,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="sovd_execute_update", - description="Execute a prepared update.", + description="Execute a prepared software update. WARNING: This triggers actual software installation on the target system. Ensure the update has been prepared successfully first.", inputSchema={ "type": "object", "properties": { @@ -2326,7 +2403,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="sovd_automate_update", - description="Run full automated update flow (prepare + execute).", + description="Run automated update workflow (prepare + execute). WARNING: This triggers actual software installation on the target system. Use with caution.", inputSchema={ "type": "object", "properties": { @@ -2701,9 +2778,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "sovd_bulkdata_upload": args = BulkDataUploadArgs(**arguments) - import base64 - - file_bytes = base64.b64decode(args.file_content) + try: + file_bytes = base64.b64decode(args.file_content) + except Exception: + return format_error("Invalid base64 encoding in file_content") result = await client.upload_bulk_data( args.entity_id, args.category, file_bytes, args.filename, args.entity_type ) @@ -2812,6 +2890,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) + elif normalized_name == "sovd_delete_script": + args = GetScriptArgs(**arguments) + result = await client.delete_script( + args.entity_id, args.script_id, args.entity_type + ) + return format_json_response(result) + # ==================== Locking ==================== elif normalized_name == "sovd_acquire_lock": diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index ee952c0..79c1a52 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -706,7 +706,8 @@ class BulkDataUploadArgs(BaseModel): ) file_content: str = Field( ..., - description="Base64-encoded file content to upload", + max_length=67_108_864, # ~50MB base64 encoded + description="Base64-encoded file content to upload (max ~50MB)", ) filename: str = Field( ..., @@ -932,6 +933,7 @@ class UploadScriptArgs(BaseModel): entity_id: str = Field(..., description="The entity identifier") script_content: str = Field( ..., + max_length=102400, # 100KB max description="The script content as a string (will be uploaded as binary)", ) entity_type: str = Field( From feaf335709db5683261b9eb675c18aea17015e09 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 15:41:17 +0200 Subject: [PATCH 09/14] fix: improve create_cyclic_sub tool description with required fields --- src/ros2_medkit_mcp/mcp_app.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 7ad1571..37262da 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -2189,7 +2189,7 @@ async def list_tools() -> list[Tool]: # ==================== Cyclic Subscriptions ==================== Tool( name="sovd_create_cyclic_sub", - description="Create a cyclic data subscription for an entity. Subscribes to periodic data updates.", + description="Create a cyclic data subscription for an entity. Subscribes to periodic data updates. Required fields in sub_config: 'resource' (data URI to observe), 'interval' ('fast', 'normal', or 'slow'), 'duration' (seconds). Optional: 'protocol' (default 'sse').", inputSchema={ "type": "object", "properties": { @@ -2199,7 +2199,20 @@ async def list_tools() -> list[Tool]: }, "sub_config": { "type": "object", - "description": "Subscription configuration (e.g., {'resource': '/data/temperature', 'period': 1000})", + "description": "Subscription config. Required: resource (string), interval ('fast'|'normal'|'slow'), duration (integer seconds). Example: {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60}", + "properties": { + "resource": { + "type": "string", + "description": "Data URI to subscribe to", + }, + "interval": {"type": "string", "enum": ["fast", "normal", "slow"]}, + "duration": { + "type": "integer", + "description": "Subscription duration in seconds", + "minimum": 1, + }, + }, + "required": ["resource", "interval", "duration"], }, "entity_type": { "type": "string", From 56f01320ec3176ac126af02574f34b81e423c95e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 20:26:19 +0200 Subject: [PATCH 10/14] fix: bump ros2-medkit-client to 0.1.1, adapt to new schemas - Update wheel dependency to py-v0.1.1 - Lock endpoints now require x_client_id header (default: ros2_medkit_mcp) - AcquireLockRequest/ExtendLockRequest are now separate models (spec fix) - extend_lock returns 204, use _call_void - ScriptControlRequest uses 'action' field not 'command' - Fix test fixtures for new lock/script schemas --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- src/ros2_medkit_mcp/client.py | 30 +++++++++++++++++++++++++----- src/ros2_medkit_mcp/mcp_app.py | 2 +- tests/test_new_tools.py | 19 +++++-------------- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/poetry.lock b/poetry.lock index 93fcd1f..9798d47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 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.0" +version = "0.1.1" description = "Async Python client for the ros2_medkit gateway" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "ros2_medkit_client-0.1.0-py3-none-any.whl", hash = "sha256:457d7738577d8b5639056e4578aab4d4696f17e0eb50b88ea6866706a02cc934"}, + {file = "ros2_medkit_client-0.1.1-py3-none-any.whl", hash = "sha256:252b0cfed6002b004262efdddd146ff3e52015034509b9083dc046ad53a811a3"}, ] [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.0/ros2_medkit_client-0.1.0-py3-none-any.whl" +url = "https://github.com/selfpatch/ros2_medkit_clients/releases/download/py-v0.1.1/ros2_medkit_client-0.1.1-py3-none-any.whl" [[package]] name = "rpds-py" @@ -1812,4 +1812,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "be551e8971b9e75cf16c89c858b8fb9c878e1f88903348d4741d196ecfec0861" +content-hash = "8713781f94abcaddb1791c3af82df6172df0361eee7afb09b8fe374e3dc6ec1c" diff --git a/pyproject.toml b/pyproject.toml index 0654dce..68652a7 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.0/ros2_medkit_client-0.1.0-py3-none-any.whl"} +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"} [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 bf4673f..2359dbe 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -1161,11 +1161,20 @@ async def delete_script( # ==================== Locking ==================== async def acquire_lock( - self, entity_id: str, lock_config: dict[str, Any], entity_type: str = "components" + self, + entity_id: str, + lock_config: dict[str, Any], + entity_type: str = "components", + client_id: str = "ros2_medkit_mcp", ) -> dict[str, Any]: fn = _entity_func("locking", "acquire", entity_type) return await self._call( - fn, **{_entity_id_kwarg(entity_type): entity_id, "body": lock_config} + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "body": lock_config, + "x_client_id": client_id, + }, ) async def list_locks( @@ -1188,23 +1197,34 @@ async def extend_lock( lock_id: str, lock_config: dict[str, Any], entity_type: str = "components", + client_id: str = "ros2_medkit_mcp", ) -> dict[str, Any]: fn = _entity_func("locking", "extend", entity_type) - return await self._call( + return await self._call_void( fn, **{ _entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id, "body": lock_config, + "x_client_id": client_id, }, ) async def release_lock( - self, entity_id: str, lock_id: str, entity_type: str = "components" + self, + entity_id: str, + lock_id: str, + entity_type: str = "components", + client_id: str = "ros2_medkit_mcp", ) -> dict[str, Any]: fn = _entity_func("locking", "release", entity_type) return await self._call_void( - fn, **{_entity_id_kwarg(entity_type): entity_id, "lock_id": lock_id} + fn, + **{ + _entity_id_kwarg(entity_type): entity_id, + "lock_id": lock_id, + "x_client_id": client_id, + }, ) # ==================== Cyclic Subscriptions ==================== diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 37262da..d288286 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -2029,7 +2029,7 @@ async def list_tools() -> list[Tool]: }, "action": { "type": "object", - "description": "Control action (e.g., {'command': 'stop'} or {'command': 'pause'})", + "description": "Control action (e.g., {'action': 'stop'} or {'action': 'pause'})", }, "entity_type": { "type": "string", diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index c2d7063..90b8de6 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -248,7 +248,7 @@ async def test_control_script_execution(self, client: SovdClient) -> None: respx.put( "http://test-sovd:8080/api/v1/components/motor/scripts/s1/executions/exec-1" ).mock(return_value=httpx.Response(200, json=execution_stopped)) - result = await client.control_script_execution("motor", "s1", "exec-1", {"command": "stop"}) + result = await client.control_script_execution("motor", "s1", "exec-1", {"action": "stop"}) assert result["status"] == "stopped" await client.close() @@ -277,9 +277,7 @@ class TestLockingTools: } LOCK_REQUEST = { - "id": "lock-1", - "lock_expiration": "2026-01-01T01:00:00Z", - "owned": True, + "lock_expiration": 3600, } @respx.mock @@ -317,21 +315,14 @@ async def test_get_lock(self, client: SovdClient) -> None: @respx.mock async def test_extend_lock(self, client: SovdClient) -> None: - extended_response = { - **self.LOCK_RESPONSE, - "lock_expiration": "2026-01-01T02:00:00Z", - } extend_body = { - "id": "lock-1", - "lock_expiration": "2026-01-01T02:00:00Z", - "owned": True, + "lock_expiration": 7200, } respx.put("http://test-sovd:8080/api/v1/components/motor/locks/lock-1").mock( - return_value=httpx.Response(200, json=extended_response) + return_value=httpx.Response(204) ) result = await client.extend_lock("motor", "lock-1", extend_body) - assert result["id"] == "lock-1" - assert result["lock_expiration"] == "2026-01-01T02:00:00+00:00" + assert result == {} await client.close() @respx.mock From 7845f1bcfeb78b2301a2e3f210bafa964032e01b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 20:34:49 +0200 Subject: [PATCH 11/14] fix: add required field schemas to create_trigger and acquire_lock tool descriptions --- src/ros2_medkit_mcp/mcp_app.py | 39 ++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index d288286..ac5ddf2 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -1810,7 +1810,7 @@ async def list_tools() -> list[Tool]: ), Tool( name="sovd_create_trigger", - description="Create a new trigger on an entity. Triggers monitor resources and fire events on change.", + description="Create a new trigger on an entity. Triggers monitor resources and fire events on change. Required fields in trigger_config: 'resource' (data URI to monitor), 'trigger_condition' (object with 'condition_type' string).", inputSchema={ "type": "object", "properties": { @@ -1820,7 +1820,25 @@ async def list_tools() -> list[Tool]: }, "trigger_config": { "type": "object", - "description": "Trigger configuration (e.g., {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60})", + "description": "Trigger config. Example: {'resource': '/data/temperature', 'trigger_condition': {'condition_type': 'on_change'}}", + "properties": { + "resource": { + "type": "string", + "description": "Data URI to monitor (e.g., '/data/temperature')", + }, + "trigger_condition": { + "type": "object", + "description": "Condition that triggers the event", + "properties": { + "condition_type": { + "type": "string", + "description": "Condition type (e.g., 'on_change', 'threshold')", + }, + }, + "required": ["condition_type"], + }, + }, + "required": ["resource", "trigger_condition"], }, "entity_type": { "type": "string", @@ -2068,7 +2086,7 @@ async def list_tools() -> list[Tool]: # ==================== Locking ==================== Tool( name="sovd_acquire_lock", - description="Acquire an exclusive lock on an entity for safe modifications.", + description="Acquire an exclusive lock on an entity for safe modifications. Required field in lock_config: 'lock_expiration' (integer, seconds until lock expires).", inputSchema={ "type": "object", "properties": { @@ -2078,7 +2096,20 @@ async def list_tools() -> list[Tool]: }, "lock_config": { "type": "object", - "description": "Lock configuration (e.g., {'duration': 60, 'reason': 'maintenance'})", + "description": "Lock config. Example: {'lock_expiration': 60}", + "properties": { + "lock_expiration": { + "type": "integer", + "description": "Lock duration in seconds", + "minimum": 1, + }, + "scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional lock scopes", + }, + }, + "required": ["lock_expiration"], }, "entity_type": { "type": "string", From 035016b130549744134c943197aa1b1211010156 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 31 Mar 2026 21:47:24 +0200 Subject: [PATCH 12/14] refactor: rename tool prefix from sovd_ to ros2_medkit_ All 84 MCP tool names now use the ros2_medkit_ prefix as their canonical name. The old sovd_ names and dot-notation aliases (sovd.version, etc.) are preserved in TOOL_ALIASES for backwards compatibility. Tool descriptions updated to reference new names. --- src/ros2_medkit_mcp/mcp_app.py | 653 ++++++++++++++++++--------------- tests/test_mcp_app.py | 17 +- tests/test_plugin_discovery.py | 6 +- 3 files changed, 377 insertions(+), 299 deletions(-) diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index ac5ddf2..660c73c 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -601,111 +601,184 @@ async def download_rosbags_for_fault( return [TextContent(type="text", text="\n".join(lines))] -# Map dotted names (from docs) to valid underscore names +# Map legacy sovd_* names and dotted names to canonical ros2_medkit_* names. +# Old sovd_* keys are kept for backwards compatibility. TOOL_ALIASES: dict[str, str] = { - "sovd.version": "sovd_version", - "sovd_version": "sovd_version", - "sovd_health": "sovd_health", - "sovd.entities.list": "sovd_entities_list", - "sovd_entities_list": "sovd_entities_list", - "sovd_areas_list": "sovd_areas_list", - "sovd_area_get": "sovd_area_get", - "sovd_components_list": "sovd_components_list", - "sovd_component_get": "sovd_component_get", - "sovd.entities.get": "sovd_entities_get", - "sovd_entities_get": "sovd_entities_get", - "sovd.faults.list": "sovd_faults_list", - "sovd_faults_list": "sovd_faults_list", - "sovd_faults_get": "sovd_faults_get", - "sovd_faults_clear": "sovd_faults_clear", - # Apps & Functions - "sovd_apps_list": "sovd_apps_list", - "sovd_apps_get": "sovd_apps_get", - "sovd_apps_dependencies": "sovd_apps_dependencies", - "sovd_functions_list": "sovd_functions_list", - "sovd_functions_get": "sovd_functions_get", - "sovd_functions_hosts": "sovd_functions_hosts", - # Area relationships - "sovd_area_components": "sovd_area_components", - "sovd_area_subareas": "sovd_area_subareas", - "sovd_area_contains": "sovd_area_contains", - # Component relationships - "sovd_component_subcomponents": "sovd_component_subcomponents", - "sovd_component_hosts": "sovd_component_hosts", - "sovd_component_dependencies": "sovd_component_dependencies", - # Entity data (entity-agnostic) - "sovd_entity_data": "sovd_entity_data", - "sovd_entity_topic_data": "sovd_entity_topic_data", - "sovd_publish_topic": "sovd_publish_topic", - # Operations - executions model - "sovd_list_operations": "sovd_list_operations", - "sovd_get_operation": "sovd_get_operation", - "sovd_create_execution": "sovd_create_execution", - "sovd_list_executions": "sovd_list_executions", - "sovd_get_execution": "sovd_get_execution", - "sovd_update_execution": "sovd_update_execution", - "sovd_cancel_execution": "sovd_cancel_execution", - # Configurations - "sovd_list_configurations": "sovd_list_configurations", - "sovd_get_configuration": "sovd_get_configuration", - "sovd_set_configuration": "sovd_set_configuration", - "sovd_delete_configuration": "sovd_delete_configuration", - "sovd_delete_all_configurations": "sovd_delete_all_configurations", - # Faults - extended - "sovd_all_faults_list": "sovd_all_faults_list", - "sovd_clear_all_faults": "sovd_clear_all_faults", - "sovd_fault_snapshots": "sovd_fault_snapshots", - "sovd_system_fault_snapshots": "sovd_system_fault_snapshots", - # Data discovery - "sovd_data_categories": "sovd_data_categories", - "sovd_data_groups": "sovd_data_groups", - # Bulk data - "sovd_bulkdata_categories": "sovd_bulkdata_categories", - "sovd_bulkdata_list": "sovd_bulkdata_list", - "sovd_bulkdata_info": "sovd_bulkdata_info", - "sovd_bulkdata_download": "sovd_bulkdata_download", - "sovd_bulkdata_download_for_fault": "sovd_bulkdata_download_for_fault", - "sovd_bulkdata_upload": "sovd_bulkdata_upload", - "sovd_bulkdata_delete": "sovd_bulkdata_delete", - # Logs - "sovd_list_logs": "sovd_list_logs", - "sovd_get_log_configuration": "sovd_get_log_configuration", - "sovd_set_log_configuration": "sovd_set_log_configuration", - # Triggers - "sovd_list_triggers": "sovd_list_triggers", - "sovd_get_trigger": "sovd_get_trigger", - "sovd_create_trigger": "sovd_create_trigger", - "sovd_update_trigger": "sovd_update_trigger", - "sovd_delete_trigger": "sovd_delete_trigger", - # Scripts - "sovd_list_scripts": "sovd_list_scripts", - "sovd_get_script": "sovd_get_script", - "sovd_upload_script": "sovd_upload_script", - "sovd_execute_script": "sovd_execute_script", - "sovd_get_script_execution": "sovd_get_script_execution", - "sovd_control_script_execution": "sovd_control_script_execution", - "sovd_delete_script": "sovd_delete_script", - # Locking - "sovd_acquire_lock": "sovd_acquire_lock", - "sovd_list_locks": "sovd_list_locks", - "sovd_get_lock": "sovd_get_lock", - "sovd_extend_lock": "sovd_extend_lock", - "sovd_release_lock": "sovd_release_lock", - # Cyclic Subscriptions - "sovd_create_cyclic_sub": "sovd_create_cyclic_sub", - "sovd_list_cyclic_subs": "sovd_list_cyclic_subs", - "sovd_get_cyclic_sub": "sovd_get_cyclic_sub", - "sovd_update_cyclic_sub": "sovd_update_cyclic_sub", - "sovd_delete_cyclic_sub": "sovd_delete_cyclic_sub", - # Software Updates - "sovd_list_updates": "sovd_list_updates", - "sovd_register_update": "sovd_register_update", - "sovd_get_update": "sovd_get_update", - "sovd_get_update_status": "sovd_get_update_status", - "sovd_prepare_update": "sovd_prepare_update", - "sovd_execute_update": "sovd_execute_update", - "sovd_automate_update": "sovd_automate_update", - "sovd_delete_update": "sovd_delete_update", + # Canonical identity entries + "ros2_medkit_version": "ros2_medkit_version", + "ros2_medkit_health": "ros2_medkit_health", + "ros2_medkit_entities_list": "ros2_medkit_entities_list", + "ros2_medkit_areas_list": "ros2_medkit_areas_list", + "ros2_medkit_area_get": "ros2_medkit_area_get", + "ros2_medkit_components_list": "ros2_medkit_components_list", + "ros2_medkit_component_get": "ros2_medkit_component_get", + "ros2_medkit_entities_get": "ros2_medkit_entities_get", + "ros2_medkit_faults_list": "ros2_medkit_faults_list", + "ros2_medkit_faults_get": "ros2_medkit_faults_get", + "ros2_medkit_faults_clear": "ros2_medkit_faults_clear", + "ros2_medkit_apps_list": "ros2_medkit_apps_list", + "ros2_medkit_apps_get": "ros2_medkit_apps_get", + "ros2_medkit_apps_dependencies": "ros2_medkit_apps_dependencies", + "ros2_medkit_functions_list": "ros2_medkit_functions_list", + "ros2_medkit_functions_get": "ros2_medkit_functions_get", + "ros2_medkit_functions_hosts": "ros2_medkit_functions_hosts", + "ros2_medkit_area_components": "ros2_medkit_area_components", + "ros2_medkit_area_subareas": "ros2_medkit_area_subareas", + "ros2_medkit_area_contains": "ros2_medkit_area_contains", + "ros2_medkit_component_subcomponents": "ros2_medkit_component_subcomponents", + "ros2_medkit_component_hosts": "ros2_medkit_component_hosts", + "ros2_medkit_component_dependencies": "ros2_medkit_component_dependencies", + "ros2_medkit_entity_data": "ros2_medkit_entity_data", + "ros2_medkit_entity_topic_data": "ros2_medkit_entity_topic_data", + "ros2_medkit_publish_topic": "ros2_medkit_publish_topic", + "ros2_medkit_list_operations": "ros2_medkit_list_operations", + "ros2_medkit_get_operation": "ros2_medkit_get_operation", + "ros2_medkit_create_execution": "ros2_medkit_create_execution", + "ros2_medkit_list_executions": "ros2_medkit_list_executions", + "ros2_medkit_get_execution": "ros2_medkit_get_execution", + "ros2_medkit_update_execution": "ros2_medkit_update_execution", + "ros2_medkit_cancel_execution": "ros2_medkit_cancel_execution", + "ros2_medkit_list_configurations": "ros2_medkit_list_configurations", + "ros2_medkit_get_configuration": "ros2_medkit_get_configuration", + "ros2_medkit_set_configuration": "ros2_medkit_set_configuration", + "ros2_medkit_delete_configuration": "ros2_medkit_delete_configuration", + "ros2_medkit_delete_all_configurations": "ros2_medkit_delete_all_configurations", + "ros2_medkit_all_faults_list": "ros2_medkit_all_faults_list", + "ros2_medkit_clear_all_faults": "ros2_medkit_clear_all_faults", + "ros2_medkit_fault_snapshots": "ros2_medkit_fault_snapshots", + "ros2_medkit_system_fault_snapshots": "ros2_medkit_system_fault_snapshots", + "ros2_medkit_data_categories": "ros2_medkit_data_categories", + "ros2_medkit_data_groups": "ros2_medkit_data_groups", + "ros2_medkit_bulkdata_categories": "ros2_medkit_bulkdata_categories", + "ros2_medkit_bulkdata_list": "ros2_medkit_bulkdata_list", + "ros2_medkit_bulkdata_info": "ros2_medkit_bulkdata_info", + "ros2_medkit_bulkdata_download": "ros2_medkit_bulkdata_download", + "ros2_medkit_bulkdata_download_for_fault": "ros2_medkit_bulkdata_download_for_fault", + "ros2_medkit_bulkdata_upload": "ros2_medkit_bulkdata_upload", + "ros2_medkit_bulkdata_delete": "ros2_medkit_bulkdata_delete", + "ros2_medkit_list_logs": "ros2_medkit_list_logs", + "ros2_medkit_get_log_configuration": "ros2_medkit_get_log_configuration", + "ros2_medkit_set_log_configuration": "ros2_medkit_set_log_configuration", + "ros2_medkit_list_triggers": "ros2_medkit_list_triggers", + "ros2_medkit_get_trigger": "ros2_medkit_get_trigger", + "ros2_medkit_create_trigger": "ros2_medkit_create_trigger", + "ros2_medkit_update_trigger": "ros2_medkit_update_trigger", + "ros2_medkit_delete_trigger": "ros2_medkit_delete_trigger", + "ros2_medkit_list_scripts": "ros2_medkit_list_scripts", + "ros2_medkit_get_script": "ros2_medkit_get_script", + "ros2_medkit_upload_script": "ros2_medkit_upload_script", + "ros2_medkit_execute_script": "ros2_medkit_execute_script", + "ros2_medkit_get_script_execution": "ros2_medkit_get_script_execution", + "ros2_medkit_control_script_execution": "ros2_medkit_control_script_execution", + "ros2_medkit_delete_script": "ros2_medkit_delete_script", + "ros2_medkit_acquire_lock": "ros2_medkit_acquire_lock", + "ros2_medkit_list_locks": "ros2_medkit_list_locks", + "ros2_medkit_get_lock": "ros2_medkit_get_lock", + "ros2_medkit_extend_lock": "ros2_medkit_extend_lock", + "ros2_medkit_release_lock": "ros2_medkit_release_lock", + "ros2_medkit_create_cyclic_sub": "ros2_medkit_create_cyclic_sub", + "ros2_medkit_list_cyclic_subs": "ros2_medkit_list_cyclic_subs", + "ros2_medkit_get_cyclic_sub": "ros2_medkit_get_cyclic_sub", + "ros2_medkit_update_cyclic_sub": "ros2_medkit_update_cyclic_sub", + "ros2_medkit_delete_cyclic_sub": "ros2_medkit_delete_cyclic_sub", + "ros2_medkit_list_updates": "ros2_medkit_list_updates", + "ros2_medkit_register_update": "ros2_medkit_register_update", + "ros2_medkit_get_update": "ros2_medkit_get_update", + "ros2_medkit_get_update_status": "ros2_medkit_get_update_status", + "ros2_medkit_prepare_update": "ros2_medkit_prepare_update", + "ros2_medkit_execute_update": "ros2_medkit_execute_update", + "ros2_medkit_automate_update": "ros2_medkit_automate_update", + "ros2_medkit_delete_update": "ros2_medkit_delete_update", + # Legacy sovd_* aliases (backwards compatibility) + "sovd_version": "ros2_medkit_version", + "sovd_health": "ros2_medkit_health", + "sovd_entities_list": "ros2_medkit_entities_list", + "sovd_areas_list": "ros2_medkit_areas_list", + "sovd_area_get": "ros2_medkit_area_get", + "sovd_components_list": "ros2_medkit_components_list", + "sovd_component_get": "ros2_medkit_component_get", + "sovd_entities_get": "ros2_medkit_entities_get", + "sovd_faults_list": "ros2_medkit_faults_list", + "sovd_faults_get": "ros2_medkit_faults_get", + "sovd_faults_clear": "ros2_medkit_faults_clear", + "sovd_apps_list": "ros2_medkit_apps_list", + "sovd_apps_get": "ros2_medkit_apps_get", + "sovd_apps_dependencies": "ros2_medkit_apps_dependencies", + "sovd_functions_list": "ros2_medkit_functions_list", + "sovd_functions_get": "ros2_medkit_functions_get", + "sovd_functions_hosts": "ros2_medkit_functions_hosts", + "sovd_area_components": "ros2_medkit_area_components", + "sovd_area_subareas": "ros2_medkit_area_subareas", + "sovd_area_contains": "ros2_medkit_area_contains", + "sovd_component_subcomponents": "ros2_medkit_component_subcomponents", + "sovd_component_hosts": "ros2_medkit_component_hosts", + "sovd_component_dependencies": "ros2_medkit_component_dependencies", + "sovd_entity_data": "ros2_medkit_entity_data", + "sovd_entity_topic_data": "ros2_medkit_entity_topic_data", + "sovd_publish_topic": "ros2_medkit_publish_topic", + "sovd_list_operations": "ros2_medkit_list_operations", + "sovd_get_operation": "ros2_medkit_get_operation", + "sovd_create_execution": "ros2_medkit_create_execution", + "sovd_list_executions": "ros2_medkit_list_executions", + "sovd_get_execution": "ros2_medkit_get_execution", + "sovd_update_execution": "ros2_medkit_update_execution", + "sovd_cancel_execution": "ros2_medkit_cancel_execution", + "sovd_list_configurations": "ros2_medkit_list_configurations", + "sovd_get_configuration": "ros2_medkit_get_configuration", + "sovd_set_configuration": "ros2_medkit_set_configuration", + "sovd_delete_configuration": "ros2_medkit_delete_configuration", + "sovd_delete_all_configurations": "ros2_medkit_delete_all_configurations", + "sovd_all_faults_list": "ros2_medkit_all_faults_list", + "sovd_clear_all_faults": "ros2_medkit_clear_all_faults", + "sovd_fault_snapshots": "ros2_medkit_fault_snapshots", + "sovd_system_fault_snapshots": "ros2_medkit_system_fault_snapshots", + "sovd_data_categories": "ros2_medkit_data_categories", + "sovd_data_groups": "ros2_medkit_data_groups", + "sovd_bulkdata_categories": "ros2_medkit_bulkdata_categories", + "sovd_bulkdata_list": "ros2_medkit_bulkdata_list", + "sovd_bulkdata_info": "ros2_medkit_bulkdata_info", + "sovd_bulkdata_download": "ros2_medkit_bulkdata_download", + "sovd_bulkdata_download_for_fault": "ros2_medkit_bulkdata_download_for_fault", + "sovd_bulkdata_upload": "ros2_medkit_bulkdata_upload", + "sovd_bulkdata_delete": "ros2_medkit_bulkdata_delete", + "sovd_list_logs": "ros2_medkit_list_logs", + "sovd_get_log_configuration": "ros2_medkit_get_log_configuration", + "sovd_set_log_configuration": "ros2_medkit_set_log_configuration", + "sovd_list_triggers": "ros2_medkit_list_triggers", + "sovd_get_trigger": "ros2_medkit_get_trigger", + "sovd_create_trigger": "ros2_medkit_create_trigger", + "sovd_update_trigger": "ros2_medkit_update_trigger", + "sovd_delete_trigger": "ros2_medkit_delete_trigger", + "sovd_list_scripts": "ros2_medkit_list_scripts", + "sovd_get_script": "ros2_medkit_get_script", + "sovd_upload_script": "ros2_medkit_upload_script", + "sovd_execute_script": "ros2_medkit_execute_script", + "sovd_get_script_execution": "ros2_medkit_get_script_execution", + "sovd_control_script_execution": "ros2_medkit_control_script_execution", + "sovd_delete_script": "ros2_medkit_delete_script", + "sovd_acquire_lock": "ros2_medkit_acquire_lock", + "sovd_list_locks": "ros2_medkit_list_locks", + "sovd_get_lock": "ros2_medkit_get_lock", + "sovd_extend_lock": "ros2_medkit_extend_lock", + "sovd_release_lock": "ros2_medkit_release_lock", + "sovd_create_cyclic_sub": "ros2_medkit_create_cyclic_sub", + "sovd_list_cyclic_subs": "ros2_medkit_list_cyclic_subs", + "sovd_get_cyclic_sub": "ros2_medkit_get_cyclic_sub", + "sovd_update_cyclic_sub": "ros2_medkit_update_cyclic_sub", + "sovd_delete_cyclic_sub": "ros2_medkit_delete_cyclic_sub", + "sovd_list_updates": "ros2_medkit_list_updates", + "sovd_register_update": "ros2_medkit_register_update", + "sovd_get_update": "ros2_medkit_get_update", + "sovd_get_update_status": "ros2_medkit_get_update_status", + "sovd_prepare_update": "ros2_medkit_prepare_update", + "sovd_execute_update": "ros2_medkit_execute_update", + "sovd_automate_update": "ros2_medkit_automate_update", + "sovd_delete_update": "ros2_medkit_delete_update", + # Dot-notation aliases (legacy) + "sovd.version": "ros2_medkit_version", + "sovd.entities.list": "ros2_medkit_entities_list", + "sovd.entities.get": "ros2_medkit_entities_get", + "sovd.faults.list": "ros2_medkit_faults_list", } @@ -728,7 +801,7 @@ async def list_tools() -> list[Tool]: tools = [ # ==================== Discovery ==================== Tool( - name="sovd_version", + name="ros2_medkit_version", description="Get the SOVD API version information from ros2_medkit gateway. Use this to verify the gateway is running.", inputSchema={ "type": "object", @@ -737,7 +810,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_health", + name="ros2_medkit_health", description="Get health status of the SOVD gateway. Returns service status.", inputSchema={ "type": "object", @@ -746,7 +819,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_entities_list", + name="ros2_medkit_entities_list", description="List all SOVD entities (areas and components combined) with optional substring filtering. This is the primary discovery tool - use it first to explore what's available in the system before querying specific components.", inputSchema={ "type": "object", @@ -760,8 +833,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_areas_list", - description="List all SOVD areas (ROS 2 namespaces). Areas are top-level groupings like 'perception', 'control', 'diagnostics'. Use this to discover available areas before listing their components with sovd_area_components.", + name="ros2_medkit_areas_list", + description="List all SOVD areas (ROS 2 namespaces). Areas are top-level groupings like 'perception', 'control', 'diagnostics'. Use this to discover available areas before listing their components with ros2_medkit_area_components.", inputSchema={ "type": "object", "properties": {}, @@ -769,7 +842,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_area_get", + name="ros2_medkit_area_get", description="Get detailed information about a specific area including its capabilities.", inputSchema={ "type": "object", @@ -783,8 +856,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_components_list", - description="List all SOVD components (ROS 2 nodes) across all areas. Returns component IDs that can be used with other tools like sovd_faults_list, sovd_entity_data, etc.", + name="ros2_medkit_components_list", + description="List all SOVD components (ROS 2 nodes) across all areas. Returns component IDs that can be used with other tools like ros2_medkit_faults_list, ros2_medkit_entity_data, etc.", inputSchema={ "type": "object", "properties": {}, @@ -792,7 +865,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_component_get", + name="ros2_medkit_component_get", description="Get detailed information about a specific component including its capabilities.", inputSchema={ "type": "object", @@ -806,8 +879,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_entities_get", - description="Get detailed information about a specific SOVD entity by its identifier, including live data if available. Use sovd_entities_list or sovd_components_list first to discover valid entity IDs.", + name="ros2_medkit_entities_get", + description="Get detailed information about a specific SOVD entity by its identifier, including live data if available. Use ros2_medkit_entities_list or ros2_medkit_components_list first to discover valid entity IDs.", inputSchema={ "type": "object", "properties": { @@ -821,14 +894,14 @@ async def list_tools() -> list[Tool]: ), # ==================== Faults ==================== Tool( - name="sovd_faults_list", - description="List all faults for a specific entity. IMPORTANT: First use sovd_components_list or sovd_area_components to discover valid entity IDs.", + name="ros2_medkit_faults_list", + description="List all faults for a specific entity. IMPORTANT: First use ros2_medkit_components_list or ros2_medkit_area_components to discover valid entity IDs.", inputSchema={ "type": "object", "properties": { "entity_id": { "type": "string", - "description": "The entity identifier (use sovd_entities_list to discover valid IDs)", + "description": "The entity identifier (use ros2_medkit_entities_list to discover valid IDs)", }, "entity_type": { "type": "string", @@ -841,8 +914,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_faults_get", - description="Get a specific fault by its code from an entity. First use sovd_faults_list to discover available faults.", + name="ros2_medkit_faults_get", + description="Get a specific fault by its code from an entity. First use ros2_medkit_faults_list to discover available faults.", inputSchema={ "type": "object", "properties": { @@ -865,8 +938,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_faults_clear", - description="Clear (acknowledge/dismiss) a fault from an entity. Use sovd_faults_list first to see active faults.", + name="ros2_medkit_faults_clear", + description="Clear (acknowledge/dismiss) a fault from an entity. Use ros2_medkit_faults_list first to see active faults.", inputSchema={ "type": "object", "properties": { @@ -889,7 +962,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_all_faults_list", + name="ros2_medkit_all_faults_list", description="List all faults across the entire system. Returns faults from all components.", inputSchema={ "type": "object", @@ -898,7 +971,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_clear_all_faults", + name="ros2_medkit_clear_all_faults", description="Clear all faults for a specific entity. WARNING: This clears ALL active faults for the entity.", inputSchema={ "type": "object", @@ -918,7 +991,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_fault_snapshots", + name="ros2_medkit_fault_snapshots", description="Get diagnostic snapshots for a specific fault. Contains data captured at fault occurrence time.", inputSchema={ "type": "object", @@ -942,7 +1015,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_system_fault_snapshots", + name="ros2_medkit_system_fault_snapshots", description="Get system-wide diagnostic snapshots for a fault code.", inputSchema={ "type": "object", @@ -956,21 +1029,21 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_area_components", - description="List all components within a specific area. Use sovd_areas_list first to discover valid area IDs (e.g., 'perception', 'control', 'diagnostics').", + name="ros2_medkit_area_components", + description="List all components within a specific area. Use ros2_medkit_areas_list first to discover valid area IDs (e.g., 'perception', 'control', 'diagnostics').", inputSchema={ "type": "object", "properties": { "area_id": { "type": "string", - "description": "The area identifier (use sovd_areas_list to discover valid IDs)", + "description": "The area identifier (use ros2_medkit_areas_list to discover valid IDs)", }, }, "required": ["area_id"], }, ), Tool( - name="sovd_area_subareas", + name="ros2_medkit_area_subareas", description="List sub-areas within an area. Use this to explore area hierarchy.", inputSchema={ "type": "object", @@ -984,7 +1057,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_area_contains", + name="ros2_medkit_area_contains", description="List all entities contained in an area (components, apps, etc.).", inputSchema={ "type": "object", @@ -999,7 +1072,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Apps ==================== Tool( - name="sovd_apps_list", + name="ros2_medkit_apps_list", description="List all SOVD apps (ROS 2 nodes). Apps are individual ROS 2 nodes that can have operations, data, configurations, and faults.", inputSchema={ "type": "object", @@ -1008,7 +1081,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_apps_get", + name="ros2_medkit_apps_get", description="Get detailed information about a specific app by its identifier.", inputSchema={ "type": "object", @@ -1022,7 +1095,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_apps_dependencies", + name="ros2_medkit_apps_dependencies", description="List dependencies for an app (other apps/components it depends on).", inputSchema={ "type": "object", @@ -1037,7 +1110,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Functions ==================== Tool( - name="sovd_functions_list", + name="ros2_medkit_functions_list", description="List all SOVD functions. Functions are capability groupings that may be hosted by multiple apps.", inputSchema={ "type": "object", @@ -1046,7 +1119,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_functions_get", + name="ros2_medkit_functions_get", description="Get detailed information about a specific function.", inputSchema={ "type": "object", @@ -1060,7 +1133,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_functions_hosts", + name="ros2_medkit_functions_hosts", description="List apps that host a specific function.", inputSchema={ "type": "object", @@ -1075,7 +1148,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Component Relationships ==================== Tool( - name="sovd_component_subcomponents", + name="ros2_medkit_component_subcomponents", description="List subcomponents of a component.", inputSchema={ "type": "object", @@ -1089,7 +1162,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_component_hosts", + name="ros2_medkit_component_hosts", description="List apps hosted by a component.", inputSchema={ "type": "object", @@ -1103,7 +1176,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_component_dependencies", + name="ros2_medkit_component_dependencies", description="List dependencies of a component.", inputSchema={ "type": "object", @@ -1118,7 +1191,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Entity Data ==================== Tool( - name="sovd_entity_data", + name="ros2_medkit_entity_data", description="Read all topic data from an entity (returns all topics with their current values). Works with components, apps, areas, and functions.", inputSchema={ "type": "object", @@ -1138,8 +1211,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_entity_topic_data", - description="Read data from a specific topic within an entity. Use sovd_entity_data first to discover available topics.", + name="ros2_medkit_entity_topic_data", + description="Read data from a specific topic within an entity. Use ros2_medkit_entity_data first to discover available topics.", inputSchema={ "type": "object", "properties": { @@ -1149,7 +1222,7 @@ async def list_tools() -> list[Tool]: }, "topic_name": { "type": "string", - "description": "The topic name (use sovd_entity_data to discover available topics)", + "description": "The topic name (use ros2_medkit_entity_data to discover available topics)", }, "entity_type": { "type": "string", @@ -1162,8 +1235,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_publish_topic", - description="Publish data to an entity's topic. Use sovd_entity_data first to verify the topic exists and check its message format.", + name="ros2_medkit_publish_topic", + description="Publish data to an entity's topic. Use ros2_medkit_entity_data first to verify the topic exists and check its message format.", inputSchema={ "type": "object", "properties": { @@ -1191,7 +1264,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Operations (Services & Actions) ==================== Tool( - name="sovd_list_operations", + name="ros2_medkit_list_operations", description="List all operations (ROS 2 services and actions) available for an entity. Works with components, apps, areas, and functions.", inputSchema={ "type": "object", @@ -1211,7 +1284,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_operation", + name="ros2_medkit_get_operation", description="Get details of a specific operation including its schema and capabilities.", inputSchema={ "type": "object", @@ -1235,7 +1308,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_create_execution", + name="ros2_medkit_create_execution", description="Start an execution for an operation (service call or action goal). For services, returns result directly. For actions, returns execution_id to track progress.", inputSchema={ "type": "object", @@ -1263,7 +1336,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_list_executions", + name="ros2_medkit_list_executions", description="List all executions for an operation. Use to see execution history and find execution IDs.", inputSchema={ "type": "object", @@ -1287,8 +1360,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_execution", - description="Get execution status and feedback for a specific execution. Use after sovd_create_execution to track action progress.", + name="ros2_medkit_get_execution", + description="Get execution status and feedback for a specific execution. Use after ros2_medkit_create_execution to track action progress.", inputSchema={ "type": "object", "properties": { @@ -1315,7 +1388,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_update_execution", + name="ros2_medkit_update_execution", description="Update an execution (e.g., stop capability). Use to control running actions.", inputSchema={ "type": "object", @@ -1347,8 +1420,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_cancel_execution", - description="Cancel a specific execution by its ID. Use sovd_list_executions to find the execution_id.", + name="ros2_medkit_cancel_execution", + description="Cancel a specific execution by its ID. Use ros2_medkit_list_executions to find the execution_id.", inputSchema={ "type": "object", "properties": { @@ -1376,7 +1449,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Configurations (ROS 2 Parameters) ==================== Tool( - name="sovd_list_configurations", + name="ros2_medkit_list_configurations", description="List all configurations (ROS 2 parameters) for an entity. Works with components, apps, areas, and functions.", inputSchema={ "type": "object", @@ -1396,8 +1469,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_configuration", - description="Get a specific configuration (parameter) value. Use sovd_list_configurations first to discover available parameters.", + name="ros2_medkit_get_configuration", + description="Get a specific configuration (parameter) value. Use ros2_medkit_list_configurations first to discover available parameters.", inputSchema={ "type": "object", "properties": { @@ -1407,7 +1480,7 @@ async def list_tools() -> list[Tool]: }, "param_name": { "type": "string", - "description": "The parameter name (use sovd_list_configurations to discover available parameters)", + "description": "The parameter name (use ros2_medkit_list_configurations to discover available parameters)", }, "entity_type": { "type": "string", @@ -1420,8 +1493,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_set_configuration", - description="Set a configuration (parameter) value. Use sovd_list_configurations first to discover available parameters and their current values.", + name="ros2_medkit_set_configuration", + description="Set a configuration (parameter) value. Use ros2_medkit_list_configurations first to discover available parameters and their current values.", inputSchema={ "type": "object", "properties": { @@ -1447,8 +1520,8 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_configuration", - description="Reset a configuration (parameter) to its default value. Use sovd_list_configurations first to see current parameter values.", + name="ros2_medkit_delete_configuration", + description="Reset a configuration (parameter) to its default value. Use ros2_medkit_list_configurations first to see current parameter values.", inputSchema={ "type": "object", "properties": { @@ -1471,7 +1544,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_all_configurations", + name="ros2_medkit_delete_all_configurations", description="Reset all configurations (parameters) for an entity to their default values. WARNING: This affects all parameters - use with caution.", inputSchema={ "type": "object", @@ -1492,7 +1565,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Data Discovery ==================== Tool( - name="sovd_data_categories", + name="ros2_medkit_data_categories", description="List data categories for an entity (e.g., topics, parameters).", inputSchema={ "type": "object", @@ -1512,7 +1585,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_data_groups", + name="ros2_medkit_data_groups", description="List data groups for an entity.", inputSchema={ "type": "object", @@ -1533,7 +1606,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Bulk Data ==================== Tool( - name="sovd_bulkdata_categories", + name="ros2_medkit_bulkdata_categories", description="List available bulk-data categories for an entity. Bulk-data categories contain downloadable files like rosbag recordings.", inputSchema={ "type": "object", @@ -1553,7 +1626,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_list", + name="ros2_medkit_bulkdata_list", description="List bulk-data items in a category. Use this to discover available rosbag recordings for download.", inputSchema={ "type": "object", @@ -1577,7 +1650,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_info", + name="ros2_medkit_bulkdata_info", description="Get information about a specific bulk-data item. Use the bulk_data_uri from fault environment_data snapshots.", inputSchema={ "type": "object", @@ -1591,7 +1664,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_download", + name="ros2_medkit_bulkdata_download", description="Download a bulk-data file (e.g., rosbag recording) to the specified directory. Use the bulk_data_uri from fault environment_data snapshots.", inputSchema={ "type": "object", @@ -1610,7 +1683,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_download_for_fault", + name="ros2_medkit_bulkdata_download_for_fault", description="Download all rosbag recordings associated with a specific fault. Retrieves the fault's environment_data and downloads all rosbag snapshots.", inputSchema={ "type": "object", @@ -1639,7 +1712,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_upload", + name="ros2_medkit_bulkdata_upload", description="Upload a file to an entity's bulk data storage.", inputSchema={ "type": "object", @@ -1671,7 +1744,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_bulkdata_delete", + name="ros2_medkit_bulkdata_delete", description="Delete a bulk data item.", inputSchema={ "type": "object", @@ -1700,7 +1773,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Logs ==================== Tool( - name="sovd_list_logs", + name="ros2_medkit_list_logs", description="List log entries for an entity. Returns recent log messages.", inputSchema={ "type": "object", @@ -1720,7 +1793,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_log_configuration", + name="ros2_medkit_get_log_configuration", description="Get log configuration for an entity.", inputSchema={ "type": "object", @@ -1740,7 +1813,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_set_log_configuration", + name="ros2_medkit_set_log_configuration", description="Update log configuration for an entity.", inputSchema={ "type": "object", @@ -1765,7 +1838,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Triggers ==================== Tool( - name="sovd_list_triggers", + name="ros2_medkit_list_triggers", description="List all triggers for an entity. Triggers monitor resource changes and generate events.", inputSchema={ "type": "object", @@ -1785,7 +1858,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_trigger", + name="ros2_medkit_get_trigger", description="Get details of a specific trigger by ID.", inputSchema={ "type": "object", @@ -1809,7 +1882,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_create_trigger", + name="ros2_medkit_create_trigger", description="Create a new trigger on an entity. Triggers monitor resources and fire events on change. Required fields in trigger_config: 'resource' (data URI to monitor), 'trigger_condition' (object with 'condition_type' string).", inputSchema={ "type": "object", @@ -1851,7 +1924,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_update_trigger", + name="ros2_medkit_update_trigger", description="Update an existing trigger's configuration.", inputSchema={ "type": "object", @@ -1879,7 +1952,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_trigger", + name="ros2_medkit_delete_trigger", description="Delete a trigger from an entity.", inputSchema={ "type": "object", @@ -1904,7 +1977,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Scripts ==================== Tool( - name="sovd_list_scripts", + name="ros2_medkit_list_scripts", description="List all scripts for an entity.", inputSchema={ "type": "object", @@ -1924,7 +1997,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_script", + name="ros2_medkit_get_script", description="Get details of a specific script.", inputSchema={ "type": "object", @@ -1948,7 +2021,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_upload_script", + name="ros2_medkit_upload_script", description="Upload a script to an entity.", inputSchema={ "type": "object", @@ -1972,7 +2045,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_execute_script", + name="ros2_medkit_execute_script", description="Execute a script on an entity. Returns execution ID.", inputSchema={ "type": "object", @@ -2000,7 +2073,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_script_execution", + name="ros2_medkit_get_script_execution", description="Get the status and result of a script execution.", inputSchema={ "type": "object", @@ -2028,7 +2101,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_control_script_execution", + name="ros2_medkit_control_script_execution", description="Control a running script execution (stop, pause, etc.).", inputSchema={ "type": "object", @@ -2060,7 +2133,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_script", + name="ros2_medkit_delete_script", description="Delete a script from an entity.", inputSchema={ "type": "object", @@ -2085,7 +2158,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Locking ==================== Tool( - name="sovd_acquire_lock", + name="ros2_medkit_acquire_lock", description="Acquire an exclusive lock on an entity for safe modifications. Required field in lock_config: 'lock_expiration' (integer, seconds until lock expires).", inputSchema={ "type": "object", @@ -2122,7 +2195,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_list_locks", + name="ros2_medkit_list_locks", description="List all active locks on an entity.", inputSchema={ "type": "object", @@ -2142,7 +2215,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_lock", + name="ros2_medkit_get_lock", description="Get details of a specific lock.", inputSchema={ "type": "object", @@ -2166,7 +2239,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_extend_lock", + name="ros2_medkit_extend_lock", description="Extend the duration of an existing lock.", inputSchema={ "type": "object", @@ -2194,7 +2267,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_release_lock", + name="ros2_medkit_release_lock", description="Release a lock on an entity.", inputSchema={ "type": "object", @@ -2219,7 +2292,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Cyclic Subscriptions ==================== Tool( - name="sovd_create_cyclic_sub", + name="ros2_medkit_create_cyclic_sub", description="Create a cyclic data subscription for an entity. Subscribes to periodic data updates. Required fields in sub_config: 'resource' (data URI to observe), 'interval' ('fast', 'normal', or 'slow'), 'duration' (seconds). Optional: 'protocol' (default 'sse').", inputSchema={ "type": "object", @@ -2256,7 +2329,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_list_cyclic_subs", + name="ros2_medkit_list_cyclic_subs", description="List all cyclic subscriptions for an entity.", inputSchema={ "type": "object", @@ -2276,7 +2349,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_cyclic_sub", + name="ros2_medkit_get_cyclic_sub", description="Get details of a specific cyclic subscription.", inputSchema={ "type": "object", @@ -2300,7 +2373,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_update_cyclic_sub", + name="ros2_medkit_update_cyclic_sub", description="Update a cyclic subscription's configuration.", inputSchema={ "type": "object", @@ -2328,7 +2401,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_cyclic_sub", + name="ros2_medkit_delete_cyclic_sub", description="Delete a cyclic subscription.", inputSchema={ "type": "object", @@ -2353,7 +2426,7 @@ async def list_tools() -> list[Tool]: ), # ==================== Software Updates ==================== Tool( - name="sovd_list_updates", + name="ros2_medkit_list_updates", description="List all registered software updates.", inputSchema={ "type": "object", @@ -2361,7 +2434,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_register_update", + name="ros2_medkit_register_update", description="Register a new software update package.", inputSchema={ "type": "object", @@ -2379,7 +2452,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_update", + name="ros2_medkit_get_update", description="Get details of a registered update.", inputSchema={ "type": "object", @@ -2393,7 +2466,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_get_update_status", + name="ros2_medkit_get_update_status", description=( "Get the current status of an update" " (pending, preparing, ready, executing, complete, failed)." @@ -2410,7 +2483,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_prepare_update", + name="ros2_medkit_prepare_update", description="Prepare an update for execution (download, verify, stage).", inputSchema={ "type": "object", @@ -2428,7 +2501,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_execute_update", + name="ros2_medkit_execute_update", description="Execute a prepared software update. WARNING: This triggers actual software installation on the target system. Ensure the update has been prepared successfully first.", inputSchema={ "type": "object", @@ -2446,7 +2519,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_automate_update", + name="ros2_medkit_automate_update", description="Run automated update workflow (prepare + execute). WARNING: This triggers actual software installation on the target system. Use with caution.", inputSchema={ "type": "object", @@ -2467,7 +2540,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="sovd_delete_update", + name="ros2_medkit_delete_update", description="Delete a registered update.", inputSchema={ "type": "object", @@ -2523,160 +2596,160 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: normalized_name = TOOL_ALIASES.get(name, name) try: - if normalized_name == "sovd_version": + if normalized_name == "ros2_medkit_version": result = await client.get_version() return format_json_response(result) - elif normalized_name == "sovd_entities_list": + elif normalized_name == "ros2_medkit_entities_list": args = EntitiesListArgs(**arguments) entities = await client.list_entities() filtered = filter_entities(entities, args.filter) return format_json_response(filtered) - elif normalized_name == "sovd_health": + elif normalized_name == "ros2_medkit_health": result = await client.get_health() return format_json_response(result) - elif normalized_name == "sovd_areas_list": + elif normalized_name == "ros2_medkit_areas_list": areas = await client.list_areas() return format_json_response(areas) - elif normalized_name == "sovd_area_get": + elif normalized_name == "ros2_medkit_area_get": args = AreaIdArgs(**arguments) area = await client.get_area(args.area_id) return format_json_response(area) - elif normalized_name == "sovd_components_list": + elif normalized_name == "ros2_medkit_components_list": components = await client.list_components() return format_json_response(components) - elif normalized_name == "sovd_component_get": + elif normalized_name == "ros2_medkit_component_get": args = ComponentIdArgs(**arguments) component = await client.get_component(args.component_id) return format_json_response(component) - elif normalized_name == "sovd_entities_get": + elif normalized_name == "ros2_medkit_entities_get": args = EntityGetArgs(**arguments) entity = await client.get_entity(args.entity_id) return format_json_response(entity) - elif normalized_name == "sovd_faults_list": + elif normalized_name == "ros2_medkit_faults_list": args = FaultsListArgs(**arguments) faults = await client.list_faults(args.entity_id, args.entity_type) return format_fault_list(faults) - elif normalized_name == "sovd_faults_get": + elif normalized_name == "ros2_medkit_faults_get": args = FaultGetArgs(**arguments) fault = await client.get_fault(args.entity_id, args.fault_id, args.entity_type) return format_fault_response(fault) - elif normalized_name == "sovd_faults_clear": + elif normalized_name == "ros2_medkit_faults_clear": args = FaultGetArgs(**arguments) result = await client.clear_fault(args.entity_id, args.fault_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_area_components": + elif normalized_name == "ros2_medkit_area_components": args = AreaComponentsArgs(**arguments) components = await client.list_area_components(args.area_id) return format_json_response(components) - elif normalized_name == "sovd_area_subareas": + elif normalized_name == "ros2_medkit_area_subareas": args = SubareasArgs(**arguments) subareas = await client.list_area_subareas(args.area_id) return format_json_response(subareas) - elif normalized_name == "sovd_area_contains": + elif normalized_name == "ros2_medkit_area_contains": args = AreaContainsArgs(**arguments) entities = await client.list_area_contains(args.area_id) return format_json_response(entities) # ==================== Apps ==================== - elif normalized_name == "sovd_apps_list": + elif normalized_name == "ros2_medkit_apps_list": apps = await client.list_apps() return format_json_response(apps) - elif normalized_name == "sovd_apps_get": + elif normalized_name == "ros2_medkit_apps_get": args = AppIdArgs(**arguments) app = await client.get_app(args.app_id) return format_json_response(app) - elif normalized_name == "sovd_apps_dependencies": + elif normalized_name == "ros2_medkit_apps_dependencies": args = AppIdArgs(**arguments) deps = await client.list_app_dependencies(args.app_id) return format_json_response(deps) # ==================== Functions ==================== - elif normalized_name == "sovd_functions_list": + elif normalized_name == "ros2_medkit_functions_list": functions = await client.list_functions() return format_json_response(functions) - elif normalized_name == "sovd_functions_get": + elif normalized_name == "ros2_medkit_functions_get": args = FunctionIdArgs(**arguments) func = await client.get_function(args.function_id) return format_json_response(func) - elif normalized_name == "sovd_functions_hosts": + elif normalized_name == "ros2_medkit_functions_hosts": args = FunctionIdArgs(**arguments) hosts = await client.list_function_hosts(args.function_id) return format_json_response(hosts) # ==================== Component Relationships ==================== - elif normalized_name == "sovd_component_subcomponents": + elif normalized_name == "ros2_medkit_component_subcomponents": args = SubcomponentsArgs(**arguments) subs = await client.list_component_subcomponents(args.component_id) return format_json_response(subs) - elif normalized_name == "sovd_component_hosts": + elif normalized_name == "ros2_medkit_component_hosts": args = ComponentHostsArgs(**arguments) hosts = await client.list_component_hosts(args.component_id) return format_json_response(hosts) - elif normalized_name == "sovd_component_dependencies": + elif normalized_name == "ros2_medkit_component_dependencies": args = DependenciesArgs(**arguments) deps = await client.list_component_dependencies(args.entity_id) return format_json_response(deps) # ==================== Extended Faults ==================== - elif normalized_name == "sovd_all_faults_list": + elif normalized_name == "ros2_medkit_all_faults_list": faults = await client.list_all_faults() return format_fault_list(faults) - elif normalized_name == "sovd_clear_all_faults": + elif normalized_name == "ros2_medkit_clear_all_faults": args = ClearAllFaultsArgs(**arguments) result = await client.clear_all_faults(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_fault_snapshots": + elif normalized_name == "ros2_medkit_fault_snapshots": args = FaultSnapshotsArgs(**arguments) snapshots = await client.get_fault_snapshots( args.entity_id, args.fault_code, args.entity_type ) return format_snapshots_response(snapshots) - elif normalized_name == "sovd_system_fault_snapshots": + elif normalized_name == "ros2_medkit_system_fault_snapshots": args = SystemFaultSnapshotsArgs(**arguments) snapshots = await client.get_system_fault_snapshots(args.fault_code) return format_snapshots_response(snapshots) # ==================== Entity Data ==================== - elif normalized_name == "sovd_entity_data": + elif normalized_name == "ros2_medkit_entity_data": args = EntityDataArgs(**arguments) data = await client.get_component_data(args.entity_id, args.entity_type) return format_json_response(data) - elif normalized_name == "sovd_entity_topic_data": + elif normalized_name == "ros2_medkit_entity_topic_data": args = EntityTopicDataArgs(**arguments) data = await client.get_component_topic_data( args.entity_id, args.topic_name, args.entity_type ) return format_json_response(data) - elif normalized_name == "sovd_publish_topic": + elif normalized_name == "ros2_medkit_publish_topic": args = PublishTopicArgs(**arguments) result = await client.publish_to_topic( args.entity_id, args.topic_name, args.data, args.entity_type @@ -2685,19 +2758,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Operations ==================== - elif normalized_name == "sovd_list_operations": + elif normalized_name == "ros2_medkit_list_operations": args = ListOperationsArgs(**arguments) operations = await client.list_operations(args.entity_id, args.entity_type) return format_json_response(operations) - elif normalized_name == "sovd_get_operation": + elif normalized_name == "ros2_medkit_get_operation": args = GetOperationArgs(**arguments) operation = await client.get_operation( args.entity_id, args.operation_name, args.entity_type ) return format_json_response(operation) - elif normalized_name == "sovd_create_execution": + elif normalized_name == "ros2_medkit_create_execution": args = CreateExecutionArgs(**arguments) result = await client.create_execution( args.entity_id, @@ -2707,14 +2780,14 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) - elif normalized_name == "sovd_list_executions": + elif normalized_name == "ros2_medkit_list_executions": args = ListExecutionsArgs(**arguments) executions = await client.list_executions( args.entity_id, args.operation_name, args.entity_type ) return format_json_response(executions) - elif normalized_name == "sovd_get_execution": + elif normalized_name == "ros2_medkit_get_execution": args = ExecutionArgs(**arguments) execution = await client.get_execution( args.entity_id, @@ -2724,7 +2797,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(execution) - elif normalized_name == "sovd_update_execution": + elif normalized_name == "ros2_medkit_update_execution": args = UpdateExecutionArgs(**arguments) result = await client.update_execution( args.entity_id, @@ -2735,7 +2808,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) - elif normalized_name == "sovd_cancel_execution": + elif normalized_name == "ros2_medkit_cancel_execution": args = ExecutionArgs(**arguments) result = await client.cancel_execution( args.entity_id, @@ -2747,80 +2820,80 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Configurations ==================== - elif normalized_name == "sovd_list_configurations": + elif normalized_name == "ros2_medkit_list_configurations": args = ListConfigurationsArgs(**arguments) configs = await client.list_configurations(args.entity_id, args.entity_type) return format_json_response(configs) - elif normalized_name == "sovd_get_configuration": + elif normalized_name == "ros2_medkit_get_configuration": args = GetConfigurationArgs(**arguments) config = await client.get_configuration( args.entity_id, args.param_name, args.entity_type ) return format_json_response(config) - elif normalized_name == "sovd_set_configuration": + elif normalized_name == "ros2_medkit_set_configuration": args = SetConfigurationArgs(**arguments) result = await client.set_configuration( args.entity_id, args.param_name, args.value, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_delete_configuration": + elif normalized_name == "ros2_medkit_delete_configuration": args = GetConfigurationArgs(**arguments) result = await client.delete_configuration( args.entity_id, args.param_name, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_delete_all_configurations": + elif normalized_name == "ros2_medkit_delete_all_configurations": args = ListConfigurationsArgs(**arguments) result = await client.delete_all_configurations(args.entity_id, args.entity_type) return format_json_response(result) # ==================== Data Discovery ==================== - elif normalized_name == "sovd_data_categories": + elif normalized_name == "ros2_medkit_data_categories": args = DataCategoriesArgs(**arguments) result = await client.list_data_categories(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_data_groups": + elif normalized_name == "ros2_medkit_data_groups": args = DataGroupsArgs(**arguments) result = await client.list_data_groups(args.entity_id, args.entity_type) return format_json_response(result) # ==================== Bulk Data ==================== - elif normalized_name == "sovd_bulkdata_categories": + elif normalized_name == "ros2_medkit_bulkdata_categories": args = BulkDataCategoriesArgs(**arguments) categories = await client.list_bulk_data_categories( args.entity_id, args.entity_type ) return format_bulkdata_categories(categories, args.entity_id) - elif normalized_name == "sovd_bulkdata_list": + elif normalized_name == "ros2_medkit_bulkdata_list": args = BulkDataListArgs(**arguments) items = await client.list_bulk_data(args.entity_id, args.category, args.entity_type) return format_bulkdata_list(items, args.entity_id, args.category) - elif normalized_name == "sovd_bulkdata_info": + elif normalized_name == "ros2_medkit_bulkdata_info": args = BulkDataInfoArgs(**arguments) info = await client.get_bulk_data_info(args.bulk_data_uri) return format_bulkdata_info(info) - elif normalized_name == "sovd_bulkdata_download": + elif normalized_name == "ros2_medkit_bulkdata_download": args = BulkDataDownloadArgs(**arguments) content, filename = await client.download_bulk_data(args.bulk_data_uri) return save_bulk_data_file(content, filename, args.bulk_data_uri, args.output_dir) - elif normalized_name == "sovd_bulkdata_download_for_fault": + elif normalized_name == "ros2_medkit_bulkdata_download_for_fault": args = BulkDataDownloadForFaultArgs(**arguments) return await download_rosbags_for_fault( client, args.entity_id, args.fault_code, args.entity_type, args.output_dir ) - elif normalized_name == "sovd_bulkdata_upload": + elif normalized_name == "ros2_medkit_bulkdata_upload": args = BulkDataUploadArgs(**arguments) try: file_bytes = base64.b64decode(args.file_content) @@ -2831,7 +2904,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) - elif normalized_name == "sovd_bulkdata_delete": + elif normalized_name == "ros2_medkit_bulkdata_delete": args = BulkDataDeleteArgs(**arguments) result = await client.delete_bulk_data_item( args.entity_id, args.category, args.item_id, args.entity_type @@ -2840,17 +2913,17 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Logs ==================== - elif normalized_name == "sovd_list_logs": + elif normalized_name == "ros2_medkit_list_logs": args = ListLogsArgs(**arguments) result = await client.list_logs(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_get_log_configuration": + elif normalized_name == "ros2_medkit_get_log_configuration": args = GetLogConfigurationArgs(**arguments) result = await client.get_log_configuration(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_set_log_configuration": + elif normalized_name == "ros2_medkit_set_log_configuration": args = SetLogConfigurationArgs(**arguments) result = await client.set_log_configuration( args.entity_id, args.config, args.entity_type @@ -2859,31 +2932,31 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Triggers ==================== - elif normalized_name == "sovd_list_triggers": + elif normalized_name == "ros2_medkit_list_triggers": args = ListTriggersArgs(**arguments) result = await client.list_triggers(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_get_trigger": + elif normalized_name == "ros2_medkit_get_trigger": args = GetTriggerArgs(**arguments) result = await client.get_trigger(args.entity_id, args.trigger_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_create_trigger": + elif normalized_name == "ros2_medkit_create_trigger": args = CreateTriggerArgs(**arguments) result = await client.create_trigger( args.entity_id, args.trigger_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_update_trigger": + elif normalized_name == "ros2_medkit_update_trigger": args = UpdateTriggerArgs(**arguments) result = await client.update_trigger( args.entity_id, args.trigger_id, args.trigger_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_delete_trigger": + elif normalized_name == "ros2_medkit_delete_trigger": args = GetTriggerArgs(**arguments) result = await client.delete_trigger( args.entity_id, args.trigger_id, args.entity_type @@ -2892,38 +2965,38 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Scripts ==================== - elif normalized_name == "sovd_list_scripts": + elif normalized_name == "ros2_medkit_list_scripts": args = ListScriptsArgs(**arguments) result = await client.list_scripts(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_get_script": + elif normalized_name == "ros2_medkit_get_script": args = GetScriptArgs(**arguments) result = await client.get_script(args.entity_id, args.script_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_upload_script": + elif normalized_name == "ros2_medkit_upload_script": args = UploadScriptArgs(**arguments) result = await client.upload_script( args.entity_id, args.script_content, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_execute_script": + elif normalized_name == "ros2_medkit_execute_script": args = ExecuteScriptArgs(**arguments) result = await client.execute_script( args.entity_id, args.script_id, args.params, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_get_script_execution": + elif normalized_name == "ros2_medkit_get_script_execution": args = GetScriptExecutionArgs(**arguments) result = await client.get_script_execution( args.entity_id, args.script_id, args.execution_id, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_control_script_execution": + elif normalized_name == "ros2_medkit_control_script_execution": args = ControlScriptExecutionArgs(**arguments) result = await client.control_script_execution( args.entity_id, @@ -2934,7 +3007,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) return format_json_response(result) - elif normalized_name == "sovd_delete_script": + elif normalized_name == "ros2_medkit_delete_script": args = GetScriptArgs(**arguments) result = await client.delete_script( args.entity_id, args.script_id, args.entity_type @@ -2943,64 +3016,64 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Locking ==================== - elif normalized_name == "sovd_acquire_lock": + elif normalized_name == "ros2_medkit_acquire_lock": args = AcquireLockArgs(**arguments) result = await client.acquire_lock( args.entity_id, args.lock_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_list_locks": + elif normalized_name == "ros2_medkit_list_locks": args = ListLocksArgs(**arguments) result = await client.list_locks(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_get_lock": + elif normalized_name == "ros2_medkit_get_lock": args = GetLockArgs(**arguments) result = await client.get_lock(args.entity_id, args.lock_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_extend_lock": + elif normalized_name == "ros2_medkit_extend_lock": args = ExtendLockArgs(**arguments) result = await client.extend_lock( args.entity_id, args.lock_id, args.lock_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_release_lock": + elif normalized_name == "ros2_medkit_release_lock": args = GetLockArgs(**arguments) result = await client.release_lock(args.entity_id, args.lock_id, args.entity_type) return format_json_response(result) # ==================== Cyclic Subscriptions ==================== - elif normalized_name == "sovd_create_cyclic_sub": + elif normalized_name == "ros2_medkit_create_cyclic_sub": args = CreateCyclicSubArgs(**arguments) result = await client.create_cyclic_subscription( args.entity_id, args.sub_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_list_cyclic_subs": + elif normalized_name == "ros2_medkit_list_cyclic_subs": args = ListCyclicSubsArgs(**arguments) result = await client.list_cyclic_subscriptions(args.entity_id, args.entity_type) return format_json_response(result) - elif normalized_name == "sovd_get_cyclic_sub": + elif normalized_name == "ros2_medkit_get_cyclic_sub": args = GetCyclicSubArgs(**arguments) result = await client.get_cyclic_subscription( args.entity_id, args.subscription_id, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_update_cyclic_sub": + elif normalized_name == "ros2_medkit_update_cyclic_sub": args = UpdateCyclicSubArgs(**arguments) result = await client.update_cyclic_subscription( args.entity_id, args.subscription_id, args.sub_config, args.entity_type ) return format_json_response(result) - elif normalized_name == "sovd_delete_cyclic_sub": + elif normalized_name == "ros2_medkit_delete_cyclic_sub": args = GetCyclicSubArgs(**arguments) result = await client.delete_cyclic_subscription( args.entity_id, args.subscription_id, args.entity_type @@ -3009,42 +3082,42 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: # ==================== Software Updates ==================== - elif normalized_name == "sovd_list_updates": + elif normalized_name == "ros2_medkit_list_updates": ListUpdatesArgs(**arguments) result = await client.list_updates() return format_json_response(result) - elif normalized_name == "sovd_register_update": + elif normalized_name == "ros2_medkit_register_update": args = RegisterUpdateArgs(**arguments) result = await client.register_update(args.update_config) return format_json_response(result) - elif normalized_name == "sovd_get_update": + elif normalized_name == "ros2_medkit_get_update": args = GetUpdateArgs(**arguments) result = await client.get_update(args.update_id) return format_json_response(result) - elif normalized_name == "sovd_get_update_status": + elif normalized_name == "ros2_medkit_get_update_status": args = GetUpdateStatusArgs(**arguments) result = await client.get_update_status(args.update_id) return format_json_response(result) - elif normalized_name == "sovd_prepare_update": + elif normalized_name == "ros2_medkit_prepare_update": args = PrepareUpdateArgs(**arguments) result = await client.prepare_update(args.update_id, args.config) return format_json_response(result) - elif normalized_name == "sovd_execute_update": + elif normalized_name == "ros2_medkit_execute_update": args = ExecuteUpdateArgs(**arguments) result = await client.execute_update(args.update_id, args.config) return format_json_response(result) - elif normalized_name == "sovd_automate_update": + elif normalized_name == "ros2_medkit_automate_update": args = AutomateUpdateArgs(**arguments) result = await client.automate_update(args.update_id, args.config) return format_json_response(result) - elif normalized_name == "sovd_delete_update": + elif normalized_name == "ros2_medkit_delete_update": args = GetUpdateArgs(**arguments) result = await client.delete_update(args.update_id) return format_json_response(result) diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py index 68093ae..3d67e00 100644 --- a/tests/test_mcp_app.py +++ b/tests/test_mcp_app.py @@ -36,15 +36,20 @@ class TestToolAliases: """Tests for tool alias resolution.""" def test_alias_dot_notation(self) -> None: - """Test dot-notation aliases resolve to underscore names.""" - assert TOOL_ALIASES.get("sovd.version") == "sovd_version" - assert TOOL_ALIASES.get("sovd.entities.list") == "sovd_entities_list" - assert TOOL_ALIASES.get("sovd.faults.list") == "sovd_faults_list" + """Test dot-notation aliases resolve to canonical names.""" + assert TOOL_ALIASES.get("sovd.version") == "ros2_medkit_version" + assert TOOL_ALIASES.get("sovd.entities.list") == "ros2_medkit_entities_list" + assert TOOL_ALIASES.get("sovd.faults.list") == "ros2_medkit_faults_list" def test_canonical_name_unchanged(self) -> None: """Test canonical names resolve to themselves.""" - assert TOOL_ALIASES.get("sovd_version") == "sovd_version" - assert TOOL_ALIASES.get("sovd_entities_list") == "sovd_entities_list" + assert TOOL_ALIASES.get("ros2_medkit_version") == "ros2_medkit_version" + assert TOOL_ALIASES.get("ros2_medkit_entities_list") == "ros2_medkit_entities_list" + + def test_legacy_sovd_aliases(self) -> None: + """Test legacy sovd_* aliases resolve to ros2_medkit_* canonical names.""" + assert TOOL_ALIASES.get("sovd_version") == "ros2_medkit_version" + assert TOOL_ALIASES.get("sovd_entities_list") == "ros2_medkit_entities_list" class TestFormatFunctions: diff --git a/tests/test_plugin_discovery.py b/tests/test_plugin_discovery.py index 0a257b9..06d864b 100644 --- a/tests/test_plugin_discovery.py +++ b/tests/test_plugin_discovery.py @@ -180,7 +180,7 @@ def name(self) -> str: def list_tools(self) -> list[Tool]: return [ Tool( - name="sovd_health", + name="ros2_medkit_health", description="Collides with built-in", inputSchema={"type": "object", "properties": {}}, ) @@ -204,8 +204,8 @@ async def shutdown(self) -> None: assert "collides with built-in tool" in caplog.text - # sovd_health should appear exactly once (the built-in) - plugin_tool_names = [t.name for t in tools if t.name == "sovd_health"] + # ros2_medkit_health should appear exactly once (the built-in) + plugin_tool_names = [t.name for t in tools if t.name == "ros2_medkit_health"] assert len(plugin_tool_names) == 1 @pytest.mark.asyncio From 118b1e295342c8d0428ac6f29d6f00ff04f1b75f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 1 Apr 2026 10:07:03 +0200 Subject: [PATCH 13/14] fix: add dispatch smoke tests and optional client_id for lock tools --- src/ros2_medkit_mcp/mcp_app.py | 37 ++++++++- src/ros2_medkit_mcp/models.py | 26 ++++-- tests/test_new_tools.py | 142 +++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src/ros2_medkit_mcp/mcp_app.py b/src/ros2_medkit_mcp/mcp_app.py index 660c73c..1c07a11 100644 --- a/src/ros2_medkit_mcp/mcp_app.py +++ b/src/ros2_medkit_mcp/mcp_app.py @@ -2190,6 +2190,11 @@ async def list_tools() -> list[Tool]: "description": "Entity type", "default": "components", }, + "client_id": { + "type": "string", + "description": "Client identifier for lock ownership tracking", + "default": "ros2_medkit_mcp", + }, }, "required": ["entity_id", "lock_config"], }, @@ -2254,7 +2259,15 @@ async def list_tools() -> list[Tool]: }, "lock_config": { "type": "object", - "description": "Lock extension configuration (e.g., {'duration': 120})", + "description": "Lock extension config. Required: lock_expiration (integer, seconds). Example: {'lock_expiration': 120}", + "properties": { + "lock_expiration": { + "type": "integer", + "description": "New lock duration in seconds", + "minimum": 1, + }, + }, + "required": ["lock_expiration"], }, "entity_type": { "type": "string", @@ -2262,6 +2275,11 @@ async def list_tools() -> list[Tool]: "description": "Entity type", "default": "components", }, + "client_id": { + "type": "string", + "description": "Client identifier for lock ownership tracking", + "default": "ros2_medkit_mcp", + }, }, "required": ["entity_id", "lock_id", "lock_config"], }, @@ -2286,6 +2304,11 @@ async def list_tools() -> list[Tool]: "description": "Entity type", "default": "components", }, + "client_id": { + "type": "string", + "description": "Client identifier for lock ownership tracking", + "default": "ros2_medkit_mcp", + }, }, "required": ["entity_id", "lock_id"], }, @@ -3019,7 +3042,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_acquire_lock": args = AcquireLockArgs(**arguments) result = await client.acquire_lock( - args.entity_id, args.lock_config, args.entity_type + args.entity_id, args.lock_config, args.entity_type, args.client_id ) return format_json_response(result) @@ -3036,13 +3059,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: elif normalized_name == "ros2_medkit_extend_lock": args = ExtendLockArgs(**arguments) result = await client.extend_lock( - args.entity_id, args.lock_id, args.lock_config, args.entity_type + args.entity_id, + args.lock_id, + args.lock_config, + args.entity_type, + args.client_id, ) return format_json_response(result) elif normalized_name == "ros2_medkit_release_lock": args = GetLockArgs(**arguments) - result = await client.release_lock(args.entity_id, args.lock_id, args.entity_type) + result = await client.release_lock( + args.entity_id, args.lock_id, args.entity_type, args.client_id + ) return format_json_response(result) # ==================== Cyclic Subscriptions ==================== diff --git a/src/ros2_medkit_mcp/models.py b/src/ros2_medkit_mcp/models.py index 79c1a52..0958b83 100644 --- a/src/ros2_medkit_mcp/models.py +++ b/src/ros2_medkit_mcp/models.py @@ -881,8 +881,8 @@ class CreateTriggerArgs(BaseModel): trigger_config: dict[str, Any] = Field( ..., description=( - "Trigger configuration" - " (e.g., {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60})" + "Trigger configuration. Required: resource (string), trigger_condition (object with condition_type)." + " Example: {'resource': '/data/temperature', 'trigger_condition': {'condition_type': 'on_change'}}" ), ) entity_type: str = Field( @@ -977,7 +977,7 @@ class ControlScriptExecutionArgs(BaseModel): execution_id: str = Field(..., description="The execution identifier") action: dict[str, Any] = Field( ..., - description="Control action (e.g., {'command': 'stop'} or {'command': 'pause'})", + description="Control action (e.g., {'action': 'stop'} or {'action': 'pause'})", ) entity_type: str = Field( default="components", @@ -994,12 +994,16 @@ class AcquireLockArgs(BaseModel): entity_id: str = Field(..., description="The entity identifier") lock_config: dict[str, Any] = Field( ..., - description="Lock configuration (e.g., {'duration': 60, 'reason': 'maintenance'})", + description="Lock configuration. Required: lock_expiration (integer, seconds). Example: {'lock_expiration': 60}", ) entity_type: str = Field( default="components", description="Entity type: 'components' or 'apps'", ) + client_id: str = Field( + default="ros2_medkit_mcp", + description="Client identifier for lock ownership tracking", + ) class ListLocksArgs(BaseModel): @@ -1021,6 +1025,10 @@ class GetLockArgs(BaseModel): default="components", description="Entity type: 'components' or 'apps'", ) + client_id: str = Field( + default="ros2_medkit_mcp", + description="Client identifier for lock ownership tracking", + ) class ExtendLockArgs(BaseModel): @@ -1030,12 +1038,16 @@ class ExtendLockArgs(BaseModel): lock_id: str = Field(..., description="The lock identifier") lock_config: dict[str, Any] = Field( ..., - description="Lock extension configuration (e.g., {'duration': 120})", + description="Lock extension configuration. Required: lock_expiration (integer, seconds). Example: {'lock_expiration': 120}", ) entity_type: str = Field( default="components", description="Entity type: 'components' or 'apps'", ) + client_id: str = Field( + default="ros2_medkit_mcp", + description="Client identifier for lock ownership tracking", + ) # ==================== Cyclic Subscriptions Argument Models ==================== @@ -1048,8 +1060,8 @@ class CreateCyclicSubArgs(BaseModel): sub_config: dict[str, Any] = Field( ..., description=( - "Subscription configuration" - " (e.g., {'resource': '/data/temperature', 'period': 1000})" + "Subscription configuration. Required: resource (string), interval ('fast'|'normal'|'slow'), duration (int seconds)." + " Example: {'resource': '/data/temperature', 'interval': 'fast', 'duration': 60}" ), ) entity_type: str = Field( diff --git a/tests/test_new_tools.py b/tests/test_new_tools.py index 90b8de6..63e428b 100644 --- a/tests/test_new_tools.py +++ b/tests/test_new_tools.py @@ -6,6 +6,7 @@ from ros2_medkit_mcp.client import SovdClient from ros2_medkit_mcp.config import Settings +from ros2_medkit_mcp.mcp_app import format_json_response @pytest.fixture @@ -613,3 +614,144 @@ async def test_upload_bulk_data_components(self, client: SovdClient) -> None: ) assert result["id"] == "uploaded-2" await client.close() + + +class TestDispatchSmoke: + """Smoke tests verifying client -> format_json_response dispatch for each tool group.""" + + @respx.mock + async def test_logs_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/logs").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + { + "id": "log-001", + "severity": "info", + "message": "OK", + "timestamp": "2026-01-01T00:00:00Z", + } + ] + }, + ) + ) + result = await client.list_logs("motor") + formatted = format_json_response(result) + assert "log-001" in formatted[0].text + assert "info" in formatted[0].text + await client.close() + + @respx.mock + async def test_triggers_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/triggers").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + { + "id": "t1", + "event_source": "/events", + "observed_resource": "/data/temperature", + "protocol": "sse", + "status": "active", + "trigger_condition": {"condition_type": "on_change"}, + } + ] + }, + ) + ) + result = await client.list_triggers("motor") + formatted = format_json_response(result) + assert "t1" in formatted[0].text + assert "on_change" in formatted[0].text + await client.close() + + @respx.mock + async def test_scripts_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/scripts").mock( + return_value=httpx.Response( + 200, + json={"items": [{"id": "s1", "name": "diagnostics.py"}]}, + ) + ) + result = await client.list_scripts("motor") + formatted = format_json_response(result) + assert "s1" in formatted[0].text + assert "diagnostics.py" in formatted[0].text + await client.close() + + @respx.mock + async def test_locking_dispatch_smoke(self, client: SovdClient) -> None: + respx.post("http://test-sovd:8080/api/v1/components/motor/locks").mock( + return_value=httpx.Response( + 201, + json={"id": "lock-1", "lock_expiration": "2026-01-01T01:00:00Z", "owned": True}, + ) + ) + result = await client.acquire_lock("motor", {"lock_expiration": 60}) + formatted = format_json_response(result) + assert "lock-1" in formatted[0].text + assert "owned" in formatted[0].text + await client.close() + + @respx.mock + async def test_subscriptions_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/cyclic-subscriptions").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + { + "id": "sub-1", + "event_source": "/events", + "interval": "fast", + "observed_resource": "/data/temperature", + "protocol": "sse", + } + ] + }, + ) + ) + result = await client.list_cyclic_subscriptions("motor") + formatted = format_json_response(result) + assert "sub-1" in formatted[0].text + assert "fast" in formatted[0].text + await client.close() + + @respx.mock + async def test_updates_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/updates").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + { + "id": "upd-1", + "name": "firmware-v2", + "version": "2.0.0", + "status": "pending", + } + ] + }, + ) + ) + result = await client.list_updates() + formatted = format_json_response(result) + assert "upd-1" in formatted[0].text + assert "firmware-v2" in formatted[0].text + await client.close() + + @respx.mock + async def test_data_discovery_dispatch_smoke(self, client: SovdClient) -> None: + respx.get("http://test-sovd:8080/api/v1/components/motor/data-categories").mock( + return_value=httpx.Response( + 200, + json={"items": ["topics", "parameters"]}, + ) + ) + result = await client.list_data_categories("motor") + formatted = format_json_response(result) + assert "topics" in formatted[0].text + assert "parameters" in formatted[0].text + await client.close() From cac6474c3acdbdfd26c89c92e090eab8994dc7fe Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 1 Apr 2026 10:12:35 +0200 Subject: [PATCH 14/14] fix: address review - stale examples, dispatch tests, lock client_id - Fix stale field examples in models.py: lock uses lock_expiration (not duration), script control uses action (not command), cyclic sub uses duration+interval+resource (not period) - Fix list_data_categories return type annotation (list[Any] not list[dict]) - Fix extend_lock tool description to use lock_expiration - Add 7 dispatch smoke tests (one per tool group) - Add optional client_id field to lock arg models (default ros2_medkit_mcp) --- src/ros2_medkit_mcp/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ros2_medkit_mcp/client.py b/src/ros2_medkit_mcp/client.py index 2359dbe..98bd66c 100644 --- a/src/ros2_medkit_mcp/client.py +++ b/src/ros2_medkit_mcp/client.py @@ -875,7 +875,7 @@ async def delete_all_configurations( async def list_data_categories( self, entity_id: str, entity_type: str = "components" - ) -> list[dict[str, Any]]: + ) -> list[Any]: fn = _entity_func("data_categories", "list", entity_type) return _extract_items(await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id}))