Read snapshots from where the gateway puts them - #24
Conversation
Both the fault formatter and the rosbag download tool read extended_data_records.rosbagSnapshots, a container the gateway has never populated, so no fault ever showed a snapshot and every download answered "no rosbag snapshots found". They come from environment_data.snapshots, keyed by type, and a fault can now hold several recordings.
There was a problem hiding this comment.
Pull request overview
Fixes snapshot handling in the MCP server to match the gateway’s actual on-the-wire fault payload shape: snapshots are read from environment_data.snapshots (typed entries) rather than from extended_data_records.*Snapshots, enabling both fault formatting and rosbag downloads to surface real freeze frames and per-occurrence recordings.
Changes:
- Add
GatewaySnapshotandEnvironmentData.snapshots(includingcaptured_atextraction from thex-medkitextension). - Update fault environment-data formatting to split snapshots by
typeand report “Rosbag Recordings (N)” (plural, per occurrence). - Update
download_rosbags_for_faultto download rosbag entries fromenvironment_data.snapshots, identified byname, and refresh tests/fixtures to reflect a real gateway response.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/ros2_medkit_mcp/models.py |
Introduces GatewaySnapshot and adds EnvironmentData.snapshots to model the gateway’s real snapshot contract. |
src/ros2_medkit_mcp/mcp_app.py |
Switches formatting and rosbag download logic to consume environment_data.snapshots and format per-type output. |
tests/test_fault_snapshot_contract.py |
Adds a contract test suite pinned to a verbatim gateway fault payload to prevent shape regressions. |
tests/test_fault_formatting.py |
Updates formatter tests to assert against the real snapshot shape and per-occurrence rosbag reporting. |
tests/test_bulkdata_tools.py |
Updates rosbag-download tests to use environment_data.snapshots and validate multi-recording downloads. |
tests/fixtures/fault_detail_two_recordings.json |
Adds a real gateway fixture demonstrating one fault with multiple rosbag recordings plus a freeze frame. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/ros2_medkit_mcp/mcp_app.py:271
- In
format_gateway_snapshot, rosbag entries always render a Download URI line even whenbulk_data_uriis missing, producingDownload URI: Noneand making the output misleading. Also,if snapshot.size_bytes:skips valid0values; prefer an explicitis not Nonecheck.
if snapshot.type == "rosbag":
# The URI carries the recording id, which is what distinguishes one
# occurrence's black box from another's.
lines.append(f" Download URI: {snapshot.bulk_data_uri}")
if snapshot.size_bytes:
src/ros2_medkit_mcp/mcp_app.py:278
format_gateway_snapshotrenders freeze-framedatavia Pythonrepr, which yields single-quoted dicts/lists and is inconsistent withformat_snapshot’s JSON formatting. Usingjson.dumps(..., default=str)keeps the output valid JSON and easier for LLMs/tools to parse.
elif snapshot.data is not None:
lines.append(f" Data: {snapshot.data}")
src/ros2_medkit_mcp/models.py:649
EnvironmentData.extended_data_recordsis still typed asExtendedDataRecords(which models snapshot containers), but its field description was changed to “First and last occurrence timestamps”. As-is, this is misleading for API consumers reading the model/schema.
extended_data_records: ExtendedDataRecords | None = Field(
default=None,
alias="extendedDataRecords",
description="First and last occurrence timestamps",
)
src/ros2_medkit_mcp/mcp_app.py:570
- Terminology is inconsistent: this branch talks about “rosbag recordings”, but a few lines below the fallback still says “No rosbag snapshots found…”. Updating that message keeps user-facing output aligned with the new
environment_data.snapshots/ recordings model.
freeze_frames = [s for s in snapshots if s.get("type") == "freeze_frame"]
if freeze_frames:
return [
TextContent(
Report a missing download URI instead of printing None, keep a zero-byte size visible, and read the fixture as UTF-8 regardless of locale.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/ros2_medkit_mcp/models.py:649
EnvironmentData.extended_data_recordsis now described as carrying first/last occurrence timestamps, butExtendedDataRecordsdoesn’t model those fields (they’d only be accepted viaextra="allow"and then be inaccessible as attributes). This makes the typed API misleading and risks silently dropping occurrence data from the model.
extended_data_records: ExtendedDataRecords | None = Field(
default=None,
alias="extendedDataRecords",
description="First and last occurrence timestamps",
)
tests/test_fault_formatting.py:417
- This file now pins that
environment_data.extended_data_recordsdoes not contain snapshot lists, but earlier in the same test moduleTestEnvironmentData.test_environment_from_apistill asserts thatEnvironmentData.model_validate()can receiveextendedDataRecords.freezeFrameSnapshots/rosbagSnapshots. That keeps coverage for the fictional shape this PR is trying to prevent and makes it easier for regressions to slip back in.
def test_extended_data_records_alone_yields_no_snapshots(self) -> None:
"""The container the gateway populates carries occurrences, not snapshots.
Reading snapshots out of extended_data_records is what made this
formatter render nothing at all for every fault; pin that it is not
where they come from.
"""
env = EnvironmentData.model_validate(
src/ros2_medkit_mcp/mcp_app.py:600
- Now that rosbag snapshots are identified by
nameand may carry aformatfield, the fallback filename generation should use that format when the gateway doesn’t return a filename; otherwise sqlite3/db3 recordings will still be saved with a misleading.mcapextension in thenot filenamebranch below.
# The recording's own name. There is no snapshotId on the wire, and a
# fault can hold several recordings, so falling back to the fault code
# would give every one of them the same identity in the report below.
snap_id = snap.get("name") or "unknown"
bulk_uri = snap.get("bulkDataUri") or snap.get("bulk_data_uri")
FaultItem now accepts the wire's numeric severity and status object, so the fault header survives formatting; ExtendedDataRecords carries the occurrence timestamps it actually holds and the formatter prints them. The two snapshot tools called a route no gateway has ever had - removed with their dead container reads. Freeze-frame data prints as JSON, the fixture pins captured_at per recording, and the contract tests assert formatter output (name, severity, status, frame value, a five-recording history) instead of the fixture dict.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/ros2_medkit_mcp/mcp_app.py:666
- The
ros2_medkit_fault_snapshots/ros2_medkit_system_fault_snapshotstools (and theirsovd_*aliases) appear to have been removed from the public tool surface (TOOL_ALIASES + tool registration/dispatch). That’s a breaking change for existing MCP clients, even if the tools were previously incorrect. Consider keeping them as deprecated wrappers that callget_fault(orfaults_get) and return the snapshot section fromenvironment_data, so existing clients keep working while still reading snapshots fromenvironment_data.snapshots.
"ros2_medkit_delete_all_configurations": "ros2_medkit_delete_all_configurations",
"ros2_medkit_all_faults_list": "ros2_medkit_all_faults_list",
"ros2_medkit_clear_all_faults": "ros2_medkit_clear_all_faults",
"ros2_medkit_data_categories": "ros2_medkit_data_categories",
"ros2_medkit_data_groups": "ros2_medkit_data_groups",
Summary
The MCP server has never shown a snapshot for any fault, and its rosbag download tool has always answered "no rosbag snapshots found". Both read
extended_data_records.rosbagSnapshots/freezeFrameSnapshots- containers the gateway does not populate and, as far as I can tell from a live response, never has. Snapshots arrive inenvironment_data.snapshots, discriminated bytype.Found while auditing consumers for ros2_medkit#620, which lets one fault keep a recording per occurrence instead of overwriting. It is not caused by that change; it is what that audit walked into.
Why it survived
The tests around this code built their fixtures from the same fiction, so the suite was green the whole time. That is the part worth fixing properly, not just the two call sites.
tests/fixtures/fault_detail_two_recordings.jsonis now a verbatim response from a running gateway holding a fault that came back twice, andtests/test_fault_snapshot_contract.pyasserts against it - including, explicitly, thatextended_data_recordscarries onlyfirst_occurrence/last_occurrenceand no snapshot containers at all. Reverting the source changes turns 3 of its 5 red.What changed
EnvironmentDatagains thesnapshotslist it was missing;GatewaySnapshotmodels an entry as sent, withcaptured_atread from thex-medkitextension where the gateway nests it rather than from a top-level field that does not exist.type. It reports Rosbag Recordings (N), plural: since #620 a fault keeps one black box per occurrence and each has its ownbulk_data_uri, which is what an LLM needs to fetch a specific one.download_rosbags_for_faultreads the same list, so it now downloads every recording a fault kept. Each is identified by itsname- the recording id - because there is nosnapshotIdon the wire and falling back to the fault code would give every recording of one fault the same identity in the report.Testing
174 passed, ruff and mypy clean. Seven existing tests encoding the old shape were rewritten against the real one rather than adjusted to keep passing.