Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
34 changes: 33 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,38 @@ 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 needs no plugin - the gateway derives it from the ROS 2 graph and,
for managed lifecycle nodes, from their reported state. Triggering a transition does: the
gateway routes it to a `LifecycleProvider` plugin registered for that entity, and no
provider ships with the gateway. A transition on a local app or component of a gateway
with no provider therefore comes back as
`501 [not-implemented] Lifecycle control not available for this entity`. An aggregating
gateway forwards requests for remote entities to the peer that owns them, so a peer that
does have a provider answers normally.

#### `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. With no provider registered for the entity the call returns the gateway error instead: `[not-implemented] Lifecycle control not available for this entity`.

## MCP Resources

### `sovd://openapi`
Expand Down
84 changes: 83 additions & 1 deletion 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 @@ -564,6 +600,10 @@ async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]:
success off `parsed is None` would report success on those errors - a
silent false-success on destructive operations. Uses the ``_detailed``
variant so the real status code is available; only 2xx is success.

A redirect counts as a failure, not a success: no endpoint documents a
3xx, redirects are not followed, and a proxy in front of the gateway
answering a destructive PUT with a 302 must not read as accepted.
"""
if "body" in kwargs and isinstance(kwargs["body"], dict):
kwargs["body"] = _wrap_body_dict(api_func, kwargs["body"])
Expand All @@ -572,7 +612,7 @@ async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]:
try:
response = await detailed(client=client.http, **kwargs)
status = int(response.status_code)
if status >= 400:
if not 200 <= status < 300:
raise SovdClientError(
message=_error_from_content(status, response.content),
status_code=status,
Expand Down Expand Up @@ -1440,6 +1480,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 gateway without one answers the"
" 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
55 changes: 53 additions & 2 deletions src/ros2_medkit_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
They validate input arguments while allowing flexible output from the API.
"""

from enum import Enum
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, Field
Expand Down Expand Up @@ -443,7 +443,7 @@ class SetConfigurationArgs(BaseModel):
# ==================== Fault Response Models ====================


class FaultStatus(str, Enum):
class FaultStatus(StrEnum):
"""Fault status values per SOVD specification."""

PENDING = "PENDING"
Expand Down Expand Up @@ -1190,6 +1190,57 @@ class AutomateUpdateArgs(BaseModel):
update_id: str = Field(..., description="The update identifier")


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


class LifecycleEntityType(StrEnum):
"""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(StrEnum):
"""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