| # | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| # | |
| from __future__ import annotations | |
| import logging | |
| from mcp.types import TextContent | |
| from iotdb_mcp_server.config import Config | |
| from iotdb_mcp_server.result_store import result_store_from_export_path | |
| from iotdb_mcp_server.services.json_response import payload_response | |
| from iotdb_mcp_server.services.target_selection import select_target_config | |
| def _cursor_to_offset(cursor: str | None, offset: int | None) -> int: | |
| if offset is not None: | |
| return max(0, int(offset)) | |
| if cursor is None or str(cursor).strip() == "": | |
| return 0 | |
| cleaned = str(cursor).strip() | |
| if not cleaned.isdigit(): | |
| raise ValueError("cursor must be a non-negative integer row offset.") | |
| return int(cleaned) | |
| def _csv_text(columns: list[str], rows: list[str]) -> str: | |
| return "\n".join([",".join(columns)] + rows) | |
| def register_result_store_tools( | |
| mcp, | |
| config: Config, | |
| logger: logging.Logger, | |
| ) -> None: | |
| """Register tools for paging file-backed MCP result sets.""" | |
| @mcp.tool() | |
| async def read_result_page( | |
| result_id: str, | |
| cursor: str | None = None, | |
| offset: int | None = None, | |
| limit: int | None = None, | |
| owner_session_id: str | None = None, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Read one page from a previously returned ResultStore result_id. | |
| Pass the same target_id used by the original query when multiple IoTDB | |
| targets are registered. cursor is the next_cursor returned by a prior | |
| page; offset can be used for random access and takes precedence. | |
| """ | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| store = result_store_from_export_path(selected_config.export_path) | |
| try: | |
| payload = store.read_page( | |
| result_id, | |
| offset=_cursor_to_offset(cursor, offset), | |
| limit=limit, | |
| owner_session_id=owner_session_id, | |
| ) | |
| payload["target_id"] = selected_config.target_id | |
| payload["text"] = _csv_text(payload["columns"], payload["rows"]) | |
| payload["inline_row_count"] = payload["returned_rows"] | |
| payload["inline_truncated"] = payload["has_more"] | |
| return payload_response( | |
| "read_result_page", | |
| payload, | |
| message="Result page loaded from ResultStore.", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to read ResultStore page: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def read_result_pages( | |
| pages: list[dict[str, object]], | |
| owner_session_id: str | None = None, | |
| default_limit: int | None = None, | |
| max_pages: int | None = None, | |
| max_total_rows: int | None = None, | |
| continue_on_error: bool = True, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Read multiple ResultStore pages in one MCP call. | |
| Each page request is an object with result_id plus optional cursor, | |
| offset, limit, and owner_session_id. offset takes precedence over | |
| cursor. owner_session_id on an item takes precedence over the shared | |
| owner_session_id argument. | |
| """ | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| store = result_store_from_export_path(selected_config.export_path) | |
| try: | |
| payload = store.read_pages( | |
| pages, | |
| owner_session_id=owner_session_id, | |
| default_limit=default_limit, | |
| max_pages=max_pages, | |
| max_total_rows=max_total_rows, | |
| continue_on_error=continue_on_error, | |
| ) | |
| payload["target_id"] = selected_config.target_id | |
| for page in payload["pages"]: | |
| if page.get("status") != "success": | |
| continue | |
| page["text"] = _csv_text(page["columns"], page["rows"]) | |
| page["inline_row_count"] = page["returned_rows"] | |
| page["inline_truncated"] = page["has_more"] | |
| return payload_response( | |
| "read_result_pages", | |
| payload, | |
| message="Result pages loaded from ResultStore.", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to read ResultStore pages: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def list_result_store( | |
| owner_session_id: str | None = None, | |
| all_sessions: bool = False, | |
| limit: int = 50, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """List ResultStore entries for the current session by default.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| store = result_store_from_export_path(selected_config.export_path) | |
| try: | |
| payload = store.list_results( | |
| owner_session_id=owner_session_id, | |
| all_sessions=all_sessions, | |
| limit=limit, | |
| ) | |
| payload["target_id"] = selected_config.target_id | |
| return payload_response( | |
| "list_result_store", | |
| payload, | |
| message="ResultStore entries listed.", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to list ResultStore entries: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def cleanup_result_store( | |
| owner_session_id: str | None = None, | |
| all_sessions: bool = False, | |
| ttl_seconds: int | None = None, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Run TTL and quota cleanup for ResultStore entries.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| store = result_store_from_export_path(selected_config.export_path) | |
| try: | |
| payload = store.cleanup( | |
| owner_session_id=owner_session_id, | |
| all_sessions=all_sessions, | |
| ttl_seconds=ttl_seconds, | |
| ) | |
| payload["target_id"] = selected_config.target_id | |
| return payload_response( | |
| "cleanup_result_store", | |
| payload, | |
| message="ResultStore cleanup completed.", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to cleanup ResultStore: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def delete_result( | |
| result_id: str, | |
| owner_session_id: str | None = None, | |
| all_sessions: bool = False, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Delete one cached ResultStore result.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| store = result_store_from_export_path(selected_config.export_path) | |
| try: | |
| payload = store.delete_result( | |
| result_id, | |
| owner_session_id=owner_session_id, | |
| all_sessions=all_sessions, | |
| ) | |
| payload["target_id"] = selected_config.target_id | |
| return payload_response( | |
| "delete_result", | |
| payload, | |
| message="ResultStore result deleted.", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to delete ResultStore result: {str(e)}") | |
| raise |