Add entity lifecycle status MCP tools (apps + components) - #23
Conversation
Expose ros2_medkit_status_get/set for apps and components (start, restart, force-restart, shutdown, force-shutdown), with sovd_* back-compat aliases. Wraps the gateway 0.6.0 lifecycle API; rejects other entity types and unknown actions.
There was a problem hiding this comment.
Pull request overview
This PR adds first-class MCP tool support for the ros2_medkit gateway (v0.6.0) entity lifecycle status API, allowing clients to query readiness state and trigger lifecycle transitions for apps and components (with sovd_* aliases preserved for backward compatibility).
Changes:
- Added lifecycle argument models (
LifecycleEntityType,LifecycleAction,StatusGetArgs,StatusSetArgs) to validate entity-type and action inputs. - Added two new MCP tools (
ros2_medkit_status_get,ros2_medkit_status_set) plussovd_status_get/sovd_status_setaliases, wiring dispatch through to the client. - Implemented lifecycle API support in
SovdClient(get_status,set_status) and added corresponding unit tests and README documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/ros2_medkit_mcp/client.py |
Adds lifecycle endpoint routing, validation, and get_status/set_status client methods backed by generated lifecycle API functions. |
src/ros2_medkit_mcp/models.py |
Introduces Pydantic argument models/enums for lifecycle status tools with strict validation (apps/components + allowed actions). |
src/ros2_medkit_mcp/mcp_app.py |
Registers the new MCP tools, adds aliases, and dispatches tool calls to the new client lifecycle methods. |
tests/test_new_tools.py |
Adds integration-style client tests for lifecycle GET/PUT behavior and input rejection cases. |
tests/test_mcp_app.py |
Adds unit tests for tool alias resolution and Pydantic validation of lifecycle argument models. |
README.md |
Documents the new lifecycle tools and their arguments/return behavior. |
A transition PUT is answered by a gateway-side LifecycleProvider plugin and no provider ships with the gateway, so on a stock gateway every status_set call comes back as 501 not-implemented while status_get works. Say so in the README and in the tool description, and describe what the tool actually returns on success: an empty JSON object, not a raw 202. Cover the paths that carried the claim: the missing-provider 501 for all five actions, both entity types against every action so a wrong _ENTITY_FUNC_MAP entry shows as a request to the wrong URL, and the registered MCP handlers - tool registry, schema enums, dispatch and the sovd_* alias - which no test reached before.
models.py switched to StrEnum for FaultStatus; the lifecycle enums are declared the same way so the import stays a single name.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
README.md:390
- The lifecycle API is introduced specifically with the gateway 0.6.0 contract, so older gateways that do not expose
/{entity_type}/{entity_id}/statuswill return an error for reads as well. Please qualify this statement (for example, “Reading the status works on gateways that expose the 0.6.0 lifecycle API”) so users do not assume compatibility with every gateway version.
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
README.md:392
- The MCP tool does not return the documented
501prefix here:_call_voidstores the status inSovdClientError.status_code, but its message for this error envelope is only[not-implemented] ..., andcall_tooldoes not appendstatus_code. This also conflicts with the exact output documented below on line 411; please describe the actual tool error (or change the formatter if the HTTP status must be exposed).
`501 [not-implemented] Lifecycle control not available for this entity`.
src/ros2_medkit_mcp/mcp_app.py:2643
- This tool description also presents
status_getas working on a stock gateway without stating the required gateway version. Since this client is pinned to and the feature is defined for gateway 0.6.0, older gateways can reject the GET endpoint; qualify the claim here so the tool metadata does not overstate compatibility.
" 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"
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
README.md:392
SovdClientErrorkeeps the HTTP status instatus_code, butcall_toolformats onlystr(e), so the tool output contains[not-implemented] Lifecycle control not available for this entitywithout the501prefix. This example disagrees with the actual response (and with the wording below); please update the documented error text or include status codes in the formatter.
`501 [not-implemented] Lifecycle control not available for this entity`.
src/ros2_medkit_mcp/mcp_app.py:3265
- At this branch, an unsupported
entity_typeoractionraisesValidationError, but the broad handler below converts it intoInternal error: .... That mislabels invalid user input as a server failure and does not provide the clear rejection promised for lifecycle arguments. Handle validation errors as input errors (or validate these arguments before dispatch) so the response is not labeled internal.
status_get_args = StatusGetArgs(**arguments)
A status outside 2xx is a failure. Only 4xx and 5xx were rejected, so a 3xx - whose parsed body is None exactly like a body-less 202 - came back as an empty success object. No endpoint documents a 3xx and redirects are not followed, but a proxy in front of the gateway answering a destructive PUT with a 302 must not read as accepted. The docs claimed more than the gateway guarantees. A transition is not universally 501 on a provider-less gateway: entity validation answers 400 and 404 first, and an aggregating gateway forwards a remote entity to the peer that owns it, so a peer with a provider answers normally. Reading the status is not unconditional either. Both claims now name the case they hold for. Tests assert what the docs promise rather than a substring of it: the exact empty object on success, the full error string the README quotes, every clause of the tool description, the 501 for both entity types, and exactly one outbound request with an empty body for a destructive transition.
The generated parsers build their error model inside the API function, so a status the SDK documents (400, 404, 500) carrying a body that is not JSON - a proxy error page rather than the gateway's own envelope - raises out of the call before _call_void sees the response. The status was lost with it, and the caller got "Failed to parse response" with status_code None: a rejected transition became indistinguishable from a client bug. An httpx response hook records the status per task, so the handler can report the real status and say plainly that the body was unparseable. A ContextVar rather than an attribute because concurrent tool calls share the client. The same loss in _call is upstream: MedkitClient.call never sees the response and hardcodes status 0.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/ros2_medkit_mcp/mcp_app.py:3272
- This validation raises
ValidationErrorbeforeSovdClient.set_statuscan produce its explicit lifecycle message, but the only matching handler below is the broadexcept Exception, so an invalid action is returned to MCP asInternal error: ...rather than as a client-input error. Catch lifecycle argument validation separately and format it as an invalid-arguments response so the rejection is not misdiagnosed as a server failure.
status_set_args = StatusSetArgs(**arguments)
README.md:390
- The phrase
Triggering a transition does:is grammatically incomplete; useTriggering a transition does this:so the lifecycle documentation is clear.
for managed lifecycle nodes, from their reported state. Triggering a transition does: the
src/ros2_medkit_mcp/client.py:1522
- This GET still goes through
_call, which converts the generated client'sNonefor an undocumented response status into{}(_to_dict(None)). As a result, a gateway/proxy failure such as HTTP 503 can be reported as a successful empty status response instead of an error, so callers cannot distinguish readiness data from an outage. Use a status-aware detailed call here (and require the status payload) before returning.
return await self._call(fn, **{_entity_id_kwarg(entity_type): entity_id})
The transition failure was written as one quoted blob, which reads as though the tool emits the 501. It does not: the gateway answers HTTP 501 and the tool reports the error envelope without the status.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/ros2_medkit_mcp/client.py:569
- The hook is installed after
self._medkitis published at line 561. A concurrent first request can therefore take the fast path at line 557 while__aenter__()is still suspended, observe an uninitialized client (or issue a request before this hook exists), and fail to record the response status. Build and fully initialize a localMedkitClient, install the hook, then assign it toself._medkitso initialization is atomic to other callers.
hooks = self._medkit.http.get_async_httpx_client().event_hooks
hooks.setdefault("response", []).append(_record_response_status)
Summary
Add
ros2_medkit_status_getandros2_medkit_status_setMCP tools (withsovd_status_get/sovd_status_setback-compat aliases), wrapping the gateway 0.6.0 entity lifecycle API.status_getreads the readiness status;status_settriggers a transition (start / restart / force-restart / shutdown / force-shutdown). Restricted to apps and components; other entity types and unknown actions are rejected with a clear error.Transitions are answered by a gateway-side
LifecycleProviderplugin and no provider ships with the gateway, so a gateway without one answers501 not-implementedwhilestatus_getstill works. The README and the tool description say so rather than promising a 202.Two error-path bugs surfaced while covering that and are fixed here:
Noneexactly like a body-less 202, so a proxy answering a destructive PUT with a redirect read as accepted. Only 2xx counts now.Failed to parse responsewith no status code. The status is preserved now.Issue
Type
Testing
In a worktree branched from
origin/main(post-0.6.0-migration):run_tests.py- 216 passedruff check src/ tests/- cleanruff format --check src/ tests/- cleanmypy src/- cleanVerified against a running gateway container rather than mocks:
status_getreturnsreadyfor an app and for the host component, and all five transitions return501 [not-implemented] Lifecycle control not available for this entitywith no lifecycle provider registered.Covered by tests: the 501 path for both entity types across all five actions, every entity_type x action route, redirect and non-JSON-error-body handling, and the registered MCP handlers (tool registry, schema enums, dispatch,
sovd_*alias), which no test reached before.Checklist
poetry run ruff check src/ tests/)poetry run ruff format --check src/ tests/)poetry run mypy src/)poetry run python run_tests.py)