Skip to content

Commit b829756

Browse files
committed
feat(tools): add bulk-data MCP tools for rosbag downloads
Add five new MCP tools for bulk-data operations: - sovd_bulkdata_categories: List bulk-data categories (rosbags, logs) - sovd_bulkdata_list: List items in a category with sizes and dates - sovd_bulkdata_info: Get metadata via HEAD request before download - sovd_bulkdata_download: Download single bulk-data file to disk - sovd_bulkdata_download_for_fault: Download all rosbags for a fault Implementation: - BulkDataItem, BulkDataCategoryResponse, BulkDataListResponse models - SovdClient methods: list_bulk_data_categories, list_bulk_data, get_bulk_data_info, download_bulk_data (with 300s timeout) - Human-readable formatting with file sizes and timestamps - Creates output_dir if not exists, extracts filename from headers/URI Tests: - TestBulkDataModels: Pydantic model validation - TestBulkDataArgModels: Argument model validation - TestFormatFunctions: Formatting function coverage - TestSaveBulkDataFile: File saving with temp directories - TestClientBulkDataMethods: HTTP client with respx mocks - TestDownloadRosbagsForFault: Multi-file download scenarios
1 parent c1d909b commit b829756

4 files changed

Lines changed: 1158 additions & 0 deletions

File tree

src/ros2_medkit_mcp/client.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,111 @@ async def delete_all_configurations(
947947
"""
948948
return await self._request("DELETE", f"/{entity_type}/{entity_id}/configurations")
949949

950+
# ==================== Bulk Data ====================
951+
952+
async def list_bulk_data_categories(
953+
self, entity_id: str, entity_type: str = "apps"
954+
) -> list[str]:
955+
"""List available bulk-data categories for an entity.
956+
957+
Args:
958+
entity_id: The entity identifier.
959+
entity_type: Entity type ('components', 'apps', 'areas', 'functions').
960+
961+
Returns:
962+
List of category names (e.g., ['rosbags', 'logs']).
963+
"""
964+
result = await self._request("GET", f"/{entity_type}/{entity_id}/bulk-data")
965+
if isinstance(result, dict) and "items" in result:
966+
return result["items"]
967+
if isinstance(result, list):
968+
return result
969+
return []
970+
971+
async def list_bulk_data(
972+
self, entity_id: str, category: str, entity_type: str = "apps"
973+
) -> list[dict[str, Any]]:
974+
"""List bulk-data items in a category.
975+
976+
Args:
977+
entity_id: The entity identifier.
978+
category: Category name (e.g., 'rosbags').
979+
entity_type: Entity type ('components', 'apps', 'areas', 'functions').
980+
981+
Returns:
982+
List of bulk data item dictionaries.
983+
"""
984+
result = await self._request("GET", f"/{entity_type}/{entity_id}/bulk-data/{category}")
985+
if isinstance(result, dict) and "items" in result:
986+
return result["items"]
987+
if isinstance(result, list):
988+
return result
989+
return []
990+
991+
async def get_bulk_data_info(self, bulk_data_uri: str) -> dict[str, Any]:
992+
"""Get metadata about a bulk-data item via HEAD request.
993+
994+
Args:
995+
bulk_data_uri: Full bulk-data URI path.
996+
997+
Returns:
998+
Dictionary with Content-Type, Content-Length, filename.
999+
"""
1000+
client = await self._ensure_client()
1001+
response = await client.head(bulk_data_uri)
1002+
1003+
if response.status_code == 404:
1004+
raise SovdClientError(
1005+
message=f"Bulk data not found: {bulk_data_uri}",
1006+
status_code=404,
1007+
)
1008+
1009+
headers = response.headers
1010+
content_disposition = headers.get("Content-Disposition", "")
1011+
filename = None
1012+
if "filename=" in content_disposition:
1013+
import re
1014+
1015+
match = re.search(r'filename="?([^"]+)"?', content_disposition)
1016+
if match:
1017+
filename = match.group(1)
1018+
1019+
return {
1020+
"content_type": headers.get("Content-Type", "application/octet-stream"),
1021+
"content_length": headers.get("Content-Length"),
1022+
"filename": filename,
1023+
"uri": bulk_data_uri,
1024+
}
1025+
1026+
async def download_bulk_data(self, bulk_data_uri: str) -> tuple[bytes, str | None]:
1027+
"""Download a bulk-data file.
1028+
1029+
Args:
1030+
bulk_data_uri: Full bulk-data URI path.
1031+
1032+
Returns:
1033+
Tuple of (file_content, filename).
1034+
"""
1035+
client = await self._ensure_client()
1036+
response = await client.get(bulk_data_uri, timeout=httpx.Timeout(300.0))
1037+
1038+
if not response.is_success:
1039+
raise SovdClientError(
1040+
message=f"Download failed: {response.status_code}",
1041+
status_code=response.status_code,
1042+
)
1043+
1044+
content_disposition = response.headers.get("Content-Disposition", "")
1045+
filename = None
1046+
if "filename=" in content_disposition:
1047+
import re
1048+
1049+
match = re.search(r'filename="?([^"]+)"?', content_disposition)
1050+
if match:
1051+
filename = match.group(1)
1052+
1053+
return response.content, filename
1054+
9501055

9511056
@asynccontextmanager
9521057
async def create_client(settings: Settings) -> AsyncIterator[SovdClient]:

0 commit comments

Comments
 (0)