Skip to content
Merged
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
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 need
one: 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 gets HTTP `501` from the gateway, which this server surfaces as
`[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
107 changes: 106 additions & 1 deletion src/ros2_medkit_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from contextvars import ContextVar
from typing import Any
from urllib.parse import quote

Expand All @@ -26,6 +27,7 @@
data,
discovery,
faults,
lifecycle,
locking,
logs,
operations,
Expand Down Expand Up @@ -102,6 +104,19 @@ def _fault_query_params(
return params


# Status of the most recent generated-client response on this task. The generated
# parsers build their error models inside the API function, so a documented error
# status (400/404/500) carrying a non-JSON body - a proxy error page, say - raises
# out of the call before the caller ever sees the response. This is how the status
# survives that. A ContextVar rather than an attribute so concurrent tool calls do
# not read each other's status.
_last_response_status: ContextVar[int | None] = ContextVar("_last_response_status", default=None)


async def _record_response_status(response: httpx.Response) -> None:
_last_response_status.set(response.status_code)


def _error_from_content(status_code: int, content: bytes) -> str:
"""Build an error message from an HTTP status and a raw response body.

Expand Down Expand Up @@ -457,8 +472,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 @@ -515,6 +565,8 @@ async def _ensure_client(self) -> MedkitClient:
)
await self._medkit.__aenter__()
self._entered = True
hooks = self._medkit.http.get_async_httpx_client().event_hooks
hooks.setdefault("response", []).append(_record_response_status)
return self._medkit

async def _httpx_client(self) -> httpx.AsyncClient:
Expand Down Expand Up @@ -564,15 +616,20 @@ 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"])
client = await self._ensure_client()
detailed = sys.modules[api_func.__module__].asyncio_detailed
_last_response_status.set(None)
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 All @@ -585,6 +642,12 @@ async def _call_void(self, api_func: Any, **kwargs: Any) -> dict[str, Any]:
except httpx.RequestError as e:
raise SovdClientError(message=f"Request failed: {e}") from e
except (ValueError, KeyError) as e:
recorded = _last_response_status.get()
if recorded is not None and not 200 <= recorded < 300:
raise SovdClientError(
message=(f"Gateway returned HTTP {recorded}: response body was not valid JSON"),
status_code=recorded,
) from e
raise SovdClientError(message=f"Failed to parse response: {e}") from e

async def _raw_request(self, method: str, path: str) -> Any:
Expand Down Expand Up @@ -1440,6 +1503,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
Loading
Loading