Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This server does **not** implement SOVD itself. It provides MCP tools that call

## Features

- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), and configurations (ROS 2 parameters)
- **Full ros2_medkit gateway coverage**: Discovery, component data, operations (services/actions), configurations (ROS 2 parameters), and entity lifecycle status (apps/components)
- **Dual transport support**: stdio and streamable-http
- **Async HTTP client** using httpx
- **Pydantic validation** for configuration and models
Expand Down Expand Up @@ -382,6 +382,29 @@ Reset all configurations (parameters) to their default values.

**Returns:** Response from `DELETE /components/{component_id}/configurations`

### Lifecycle Tools

Lifecycle status is only available for apps and components (not areas or functions).

#### `ros2_medkit_status_get`
Get the lifecycle status of an app or component (e.g. `ready` / `notReady`).

**Arguments:**
- `entity_type` (required, string): `apps` or `components`
- `entity_id` (required, string): The entity identifier

**Returns:** Response from `GET /{entity_type}/{entity_id}/status`

#### `ros2_medkit_status_set`
Trigger a lifecycle transition on an app or component. **Warning:** `shutdown`, `force-shutdown`, `restart`, and `force-restart` affect the running node or host process.

**Arguments:**
- `entity_type` (required, string): `apps` or `components`
- `entity_id` (required, string): The entity identifier
- `action` (required, string): one of `start`, `restart`, `force-restart`, `shutdown`, `force-shutdown`

**Returns:** `202 Accepted` from `PUT /{entity_type}/{entity_id}/status/{action}` (no body)

## MCP Resources

### `sovd://openapi`
Expand Down
78 changes: 78 additions & 0 deletions src/ros2_medkit_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
data,
discovery,
faults,
lifecycle,
locking,
logs,
operations,
Expand Down Expand Up @@ -457,8 +458,43 @@ def _validate_relative_uri(uri: str) -> None:
"functions": subscriptions.delete_function_subscription,
},
},
# Lifecycle is exposed only for apps and components (no areas/functions).
# Action keys use the hyphenated SOVD action names (force-restart,
# force-shutdown); the generated modules use underscores.
"lifecycle": {
"get": {
"components": lifecycle.get_components_status,
"apps": lifecycle.get_apps_status,
},
"start": {
"components": lifecycle.put_components_status_start,
"apps": lifecycle.put_apps_status_start,
},
"restart": {
"components": lifecycle.put_components_status_restart,
"apps": lifecycle.put_apps_status_restart,
},
"force-restart": {
"components": lifecycle.put_components_status_force_restart,
"apps": lifecycle.put_apps_status_force_restart,
},
"shutdown": {
"components": lifecycle.put_components_status_shutdown,
"apps": lifecycle.put_apps_status_shutdown,
},
"force-shutdown": {
"components": lifecycle.put_components_status_force_shutdown,
"apps": lifecycle.put_apps_status_force_shutdown,
},
},
}

# Lifecycle is exposed only for apps and components.
_LIFECYCLE_ENTITY_TYPES = frozenset({"apps", "components"})

# Valid lifecycle transition actions (hyphenated SOVD action names).
_LIFECYCLE_ACTIONS = frozenset({"start", "restart", "force-restart", "shutdown", "force-shutdown"})


# Validate all function references at import time
for _resource, _methods in _ENTITY_FUNC_MAP.items():
Expand Down Expand Up @@ -1440,6 +1476,48 @@ async def _call_update_action(self, api_func: Any, **kwargs: Any) -> dict[str, A
async def delete_update(self, update_id: str) -> dict[str, Any]:
return await self._call_void(updates.delete_update.asyncio, update_id=update_id)

# ==================== Lifecycle ====================

async def get_status(self, entity_type: str, entity_id: str) -> dict[str, Any]:
"""Get the lifecycle status of an app or component.

Lifecycle is exposed only for ``apps`` and ``components``; any other
entity_type raises SovdClientError.
"""
if entity_type not in _LIFECYCLE_ENTITY_TYPES:
raise SovdClientError(
message=(
f"Lifecycle status is only available for apps and components, "
f"not '{entity_type}'"
)
)
fn = _entity_func("lifecycle", "get", entity_type)
return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})

async def set_status(self, entity_type: str, entity_id: str, action: str) -> dict[str, Any]:
"""Trigger a lifecycle transition on an app or component.

``action`` is one of start, restart, force-restart, shutdown,
force-shutdown. The transition PUTs are body-less and return 202.
Lifecycle is exposed only for ``apps`` and ``components``.
"""
if entity_type not in _LIFECYCLE_ENTITY_TYPES:
raise SovdClientError(
message=(
f"Lifecycle transitions are only available for apps and "
f"components, not '{entity_type}'"
)
)
if action not in _LIFECYCLE_ACTIONS:
raise SovdClientError(
message=(
f"Unknown lifecycle action '{action}'; expected one of "
f"{', '.join(sorted(_LIFECYCLE_ACTIONS))}"
)
)
fn = _entity_func("lifecycle", action, entity_type)
return await self._call_void(fn, **{_entity_id_kwarg(entity_type): entity_id})


@asynccontextmanager
async def create_client(settings: Settings) -> AsyncIterator[SovdClient]:
Expand Down
85 changes: 85 additions & 0 deletions src/ros2_medkit_mcp/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
RosbagSnapshot,
SetConfigurationArgs,
SetLogConfigurationArgs,
StatusGetArgs,
StatusSetArgs,
SubareasArgs,
SubcomponentsArgs,
SystemFaultSnapshotsArgs,
Expand Down Expand Up @@ -690,6 +692,8 @@ async def download_rosbags_for_fault(
"ros2_medkit_execute_update": "ros2_medkit_execute_update",
"ros2_medkit_automate_update": "ros2_medkit_automate_update",
"ros2_medkit_delete_update": "ros2_medkit_delete_update",
"ros2_medkit_status_get": "ros2_medkit_status_get",
"ros2_medkit_status_set": "ros2_medkit_status_set",
# Legacy sovd_* aliases (backwards compatibility)
"sovd_version": "ros2_medkit_version",
"sovd_health": "ros2_medkit_health",
Expand Down Expand Up @@ -775,6 +779,8 @@ async def download_rosbags_for_fault(
"sovd_execute_update": "ros2_medkit_execute_update",
"sovd_automate_update": "ros2_medkit_automate_update",
"sovd_delete_update": "ros2_medkit_delete_update",
"sovd_status_get": "ros2_medkit_status_get",
"sovd_status_set": "ros2_medkit_status_set",
# Dot-notation aliases (legacy)
"sovd.version": "ros2_medkit_version",
"sovd.entities.list": "ros2_medkit_entities_list",
Expand Down Expand Up @@ -2601,6 +2607,67 @@ async def list_tools() -> list[Tool]:
"required": ["update_id"],
},
),
Tool(
name="ros2_medkit_status_get",
description=(
"Get the lifecycle status of an app or component"
" (e.g. ready / notReady). Lifecycle is only available for"
" apps and components."
),
inputSchema={
"type": "object",
"properties": {
"entity_type": {
"type": "string",
"enum": ["apps", "components"],
"description": "Entity type: 'apps' or 'components'",
},
"entity_id": {
"type": "string",
"description": "The entity identifier",
},
},
"required": ["entity_type", "entity_id"],
},
),
Tool(
name="ros2_medkit_status_set",
description=(
"Trigger a lifecycle transition on an app or component."
" WARNING: shutdown/force-shutdown/restart/force-restart"
" affect the running node or host process. Lifecycle is only"
" available for apps and components."
),
inputSchema={
"type": "object",
"properties": {
"entity_type": {
"type": "string",
"enum": ["apps", "components"],
"description": "Entity type: 'apps' or 'components'",
},
"entity_id": {
"type": "string",
"description": "The entity identifier",
},
"action": {
"type": "string",
"enum": [
"start",
"restart",
"force-restart",
"shutdown",
"force-shutdown",
],
"description": (
"Lifecycle transition: 'start', 'restart',"
" 'force-restart', 'shutdown', or 'force-shutdown'"
),
},
},
"required": ["entity_type", "entity_id", "action"],
},
),
]
# Append plugin tools
if plugins:
Expand Down Expand Up @@ -3187,6 +3254,24 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
result = await client.delete_update(args.update_id)
return format_json_response(result)

# ==================== Lifecycle ====================

elif normalized_name == "ros2_medkit_status_get":
status_get_args = StatusGetArgs(**arguments)
result = await client.get_status(
status_get_args.entity_type.value, status_get_args.entity_id
)
return format_json_response(result)

elif normalized_name == "ros2_medkit_status_set":
status_set_args = StatusSetArgs(**arguments)
result = await client.set_status(
status_set_args.entity_type.value,
status_set_args.entity_id,
status_set_args.action.value,
)
return format_json_response(result)

else:
# Check plugin tool map before reporting unknown tool
plugin = plugin_tool_map.get(normalized_name)
Expand Down
51 changes: 51 additions & 0 deletions src/ros2_medkit_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,57 @@ class AutomateUpdateArgs(BaseModel):
update_id: str = Field(..., description="The update identifier")


# ==================== Lifecycle Argument Models ====================


class LifecycleEntityType(str, Enum):
"""Entity types that support the lifecycle status API.

The gateway exposes lifecycle only for apps and components (not areas
or functions).
"""

APPS = "apps"
COMPONENTS = "components"


class LifecycleAction(str, Enum):
"""Lifecycle transition actions (hyphenated SOVD action names)."""

START = "start"
RESTART = "restart"
FORCE_RESTART = "force-restart"
SHUTDOWN = "shutdown"
FORCE_SHUTDOWN = "force-shutdown"


class StatusGetArgs(BaseModel):
"""Arguments for ros2_medkit_status_get tool."""

entity_type: LifecycleEntityType = Field(
...,
description="Entity type: 'apps' or 'components'",
)
entity_id: str = Field(..., description="The entity identifier")


class StatusSetArgs(BaseModel):
"""Arguments for ros2_medkit_status_set tool."""

entity_type: LifecycleEntityType = Field(
...,
description="Entity type: 'apps' or 'components'",
)
entity_id: str = Field(..., description="The entity identifier")
action: LifecycleAction = Field(
...,
description=(
"Lifecycle transition: 'start', 'restart', 'force-restart', "
"'shutdown', or 'force-shutdown'"
),
)


class ToolResult(BaseModel):
"""Standard result wrapper for tool responses."""

Expand Down
43 changes: 43 additions & 0 deletions tests/test_mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
import pytest
import respx
from mcp.types import TextContent
from pydantic import ValidationError

from ros2_medkit_mcp.client import SovdClient, SovdClientError
from ros2_medkit_mcp.config import Settings
from ros2_medkit_mcp.mcp_app import TOOL_ALIASES, format_error, format_json_response
from ros2_medkit_mcp.models import (
EntitiesListArgs,
FaultsListArgs,
LifecycleAction,
LifecycleEntityType,
ListOperationsArgs,
StatusGetArgs,
StatusSetArgs,
filter_entities,
)

Expand Down Expand Up @@ -51,6 +56,13 @@ def test_legacy_sovd_aliases(self) -> None:
assert TOOL_ALIASES.get("sovd_version") == "ros2_medkit_version"
assert TOOL_ALIASES.get("sovd_entities_list") == "ros2_medkit_entities_list"

def test_lifecycle_aliases(self) -> None:
"""Test lifecycle status tool aliases resolve to canonical names."""
assert TOOL_ALIASES.get("ros2_medkit_status_get") == "ros2_medkit_status_get"
assert TOOL_ALIASES.get("ros2_medkit_status_set") == "ros2_medkit_status_set"
assert TOOL_ALIASES.get("sovd_status_get") == "ros2_medkit_status_get"
assert TOOL_ALIASES.get("sovd_status_set") == "ros2_medkit_status_set"


class TestFormatFunctions:
"""Tests for response formatting functions."""
Expand Down Expand Up @@ -303,3 +315,34 @@ def test_entities_list_args_optional_filter(self) -> None:

args_with_filter = EntitiesListArgs(filter="test")
assert args_with_filter.filter == "test"

def test_status_get_args_valid(self) -> None:
"""Test StatusGetArgs accepts apps and components entity types."""
args = StatusGetArgs(entity_type="apps", entity_id="motor")
assert args.entity_type is LifecycleEntityType.APPS
assert args.entity_id == "motor"

args = StatusGetArgs(entity_type="components", entity_id="ecu")
assert args.entity_type is LifecycleEntityType.COMPONENTS

def test_status_get_args_rejects_invalid_entity_type(self) -> None:
"""Test StatusGetArgs rejects entity types without lifecycle support."""
with pytest.raises(ValidationError):
StatusGetArgs(entity_type="areas", entity_id="powertrain")

def test_status_set_args_valid_actions(self) -> None:
"""Test StatusSetArgs accepts all five lifecycle transitions."""
for action, expected in (
("start", LifecycleAction.START),
("restart", LifecycleAction.RESTART),
("force-restart", LifecycleAction.FORCE_RESTART),
("shutdown", LifecycleAction.SHUTDOWN),
("force-shutdown", LifecycleAction.FORCE_SHUTDOWN),
):
args = StatusSetArgs(entity_type="apps", entity_id="motor", action=action)
assert args.action is expected

def test_status_set_args_rejects_invalid_action(self) -> None:
"""Test StatusSetArgs rejects unknown actions."""
with pytest.raises(ValidationError):
StatusSetArgs(entity_type="apps", entity_id="motor", action="bogus")
Loading
Loading