Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
30 changes: 29 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,34 @@ 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).

Reading the status works on any gateway. Triggering a transition requires the gateway
to have a `LifecycleProvider` plugin registered for the entity; there is no built-in
provider, so on a stock gateway every `status_set` call fails with
`501 [not-implemented] Lifecycle control not available for this entity`.

#### `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 via `PUT /{entity_type}/{entity_id}/status/{action}`. Requires a gateway-side `LifecycleProvider` plugin for the entity. **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:** `{}` - the gateway accepts the transition with a body-less `202`, which the tool renders as an empty JSON object. Without a `LifecycleProvider` plugin the call returns the gateway error instead: `[not-implemented] Lifecycle control not available for this entity`.

## 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
90 changes: 90 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,72 @@ 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. Requires a gateway-side"
" LifecycleProvider plugin for the entity; there is no"
" built-in provider, so a stock gateway answers every"
" transition with 'not-implemented' while status_get still"
" works. On success the gateway returns a body-less 202 and"
" this tool returns an empty JSON object."
),
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 +3259,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
Loading
Loading