| # | |
| # 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. | |
| # | |
| import json | |
| from contextvars import ContextVar | |
| from datetime import datetime, timezone | |
| from typing import Any, Callable, Iterable | |
| from mcp.types import TextContent | |
| from iotdb_mcp_server.result_store import FileResultStore | |
| _RESPONSE_VERSION = "1.0" | |
| _target_context: ContextVar[dict[str, Any] | None] = ContextVar( | |
| "iotdb_target_context", default=None | |
| ) | |
| _TRUE_VALUES = {"1", "true", "yes", "on", "available", "running", "enabled"} | |
| _FALSE_VALUES = {"0", "false", "no", "off", "unavailable", "stopped", "disabled"} | |
| def set_iotdb_target_context(target: dict[str, Any] | None): | |
| return _target_context.set(target) | |
| def reset_iotdb_target_context(token) -> None: | |
| _target_context.reset(token) | |
| def _coerce_availability(value: Any) -> str | None: | |
| if value is None: | |
| return None | |
| if isinstance(value, bool): | |
| return "yes" if value else "no" | |
| normalized = str(value).strip().lower() | |
| if normalized in _TRUE_VALUES: | |
| return "yes" | |
| if normalized in _FALSE_VALUES: | |
| return "no" | |
| if normalized in {"unknown", "probe_failed", "not_probed"}: | |
| return "unknown" | |
| return None | |
| def _ainode_availability(target: dict[str, Any]) -> tuple[str, Any]: | |
| availability = _coerce_availability(target.get("ainode_available")) or "unknown" | |
| source = target.get("ainode_availability_source") | |
| if source is None: | |
| source = { | |
| "method": "SHOW AINODES", | |
| "ok": False, | |
| "error": "not_probed", | |
| } | |
| return availability, source | |
| def _cli_endpoint(target: dict[str, Any]) -> str: | |
| node_urls = target.get("node_urls") | |
| if isinstance(node_urls, list) and node_urls: | |
| first = str(node_urls[0]).strip() | |
| if first: | |
| return first | |
| host = str(target.get("host") or "127.0.0.1").strip() | |
| port = str(target.get("port") or "6667").strip() | |
| return f"{host}:{port}" | |
| def _row_count(payload: Any) -> int: | |
| if not isinstance(payload, dict): | |
| return 0 | |
| rows = payload.get("rows") | |
| if isinstance(rows, list): | |
| return len(rows) | |
| for key in ("row_count", "rows_count", "count"): | |
| value = payload.get(key) | |
| if isinstance(value, int): | |
| return value | |
| if isinstance(value, str) and value.strip().isdigit(): | |
| return int(value.strip()) | |
| return 0 | |
| def _iotdb_context(tool: str, payload: Any, target: dict[str, Any]) -> dict[str, Any]: | |
| ainode_available, ainode_source = _ainode_availability(target) | |
| return { | |
| "target_id": target.get("target_id"), | |
| "cli_endpoint": _cli_endpoint(target), | |
| "ainode_available": ainode_available, | |
| "ainode_availability_source": ainode_source, | |
| "dialect": target.get("sql_dialect"), | |
| "database": target.get("database"), | |
| "tool": tool, | |
| "rows": _row_count(payload), | |
| } | |
| def _render_iotdb_context(context: dict[str, Any]) -> str: | |
| lines = [ | |
| "TimeSeek IoTDB context:", | |
| f"- target_id: {context.get('target_id') or ''}", | |
| f"- cli_endpoint: {context.get('cli_endpoint') or ''}", | |
| f"- ainode_available: {context.get('ainode_available') or 'unknown'}", | |
| f"- dialect: {context.get('dialect') or ''}", | |
| f"- database: {context.get('database') or ''}", | |
| f"- tool: {context.get('tool') or ''}", | |
| f"- rows: {context.get('rows', 0)}", | |
| ] | |
| return "\n".join(lines) | |
| class JsonParser: | |
| """Simple parser/checker for MCP tool JSON envelopes.""" | |
| _required_keys = ("version", "tool", "ok", "timestamp", "payload") | |
| @staticmethod | |
| def check_format(obj: Any) -> bool: | |
| if not isinstance(obj, dict): | |
| return False | |
| for key in JsonParser._required_keys: | |
| if key not in obj: | |
| return False | |
| if not isinstance(obj["version"], str): | |
| return False | |
| if not isinstance(obj["tool"], str) or not obj["tool"].strip(): | |
| return False | |
| if not isinstance(obj["ok"], bool): | |
| return False | |
| if not isinstance(obj["timestamp"], str) or not obj["timestamp"].strip(): | |
| return False | |
| return True | |
| @staticmethod | |
| def parse(text: str) -> dict[str, Any]: | |
| try: | |
| obj = json.loads(text) | |
| except json.JSONDecodeError as e: | |
| raise ValueError(f"Invalid JSON text: {e.msg}") from e | |
| if not JsonParser.check_format(obj): | |
| raise ValueError( | |
| "Invalid wrapped response format. Expected keys: " | |
| "version, tool, ok, timestamp, payload." | |
| ) | |
| return obj | |
| def _envelope( | |
| tool: str, payload: Any, ok: bool = True, message: str | None = None | |
| ) -> dict[str, Any]: | |
| target = _target_context.get() | |
| context = _iotdb_context(tool, payload, target) if target else None | |
| if target: | |
| if isinstance(payload, dict): | |
| payload = dict(payload) | |
| payload.setdefault("iotdb_target", target) | |
| if context: | |
| payload.setdefault("iotdb_context", context) | |
| else: | |
| payload = { | |
| "value": payload, | |
| "iotdb_target": target, | |
| } | |
| if context: | |
| payload["iotdb_context"] = context | |
| obj: dict[str, Any] = { | |
| "version": _RESPONSE_VERSION, | |
| "tool": tool, | |
| "ok": ok, | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "payload": payload, | |
| } | |
| if context: | |
| obj["context"] = {"iotdb": context} | |
| if message is not None: | |
| obj["message"] = message | |
| return obj | |
| def _to_text_content(obj: dict[str, Any]) -> list[TextContent]: | |
| if not JsonParser.check_format(obj): | |
| raise ValueError("JsonParser format check failed before output.") | |
| text = json.dumps(obj, ensure_ascii=False) | |
| JsonParser.parse(text) | |
| context = obj.get("context") | |
| iotdb_context = context.get("iotdb") if isinstance(context, dict) else None | |
| if isinstance(iotdb_context, dict): | |
| return [ | |
| TextContent(type="text", text=_render_iotdb_context(iotdb_context)), | |
| TextContent(type="text", text=text), | |
| ] | |
| return [TextContent(type="text", text=text)] | |
| def payload_response( | |
| tool: str, payload: Any, message: str | None = None | |
| ) -> list[TextContent]: | |
| return _to_text_content(_envelope(tool=tool, payload=payload, message=message)) | |
| def error_response(tool: str, error: str) -> list[TextContent]: | |
| return _to_text_content( | |
| _envelope( | |
| tool=tool, | |
| payload={"error": error}, | |
| ok=False, | |
| message="Tool execution failed.", | |
| ) | |
| ) | |
| def text_payload_response( | |
| tool: str, text: str, message: str | None = None | |
| ) -> list[TextContent]: | |
| return payload_response( | |
| tool=tool, | |
| payload={"format": "text", "text": text}, | |
| message=message, | |
| ) | |
| def _csv_text(columns: list[str], rows: list[str]) -> str: | |
| return "\n".join([",".join(columns)] + rows) | |
| def csv_payload_response( | |
| tool: str, | |
| columns: list[str], | |
| rows: list[str], | |
| message: str | None = None, | |
| diagnostics: list[dict[str, Any]] | None = None, | |
| ) -> list[TextContent]: | |
| csv_text = _csv_text(columns, rows) | |
| payload: dict[str, Any] = { | |
| "format": "csv", | |
| "columns": columns, | |
| "rows": rows, | |
| "text": csv_text, | |
| "row_count": len(rows), | |
| "inline_row_count": len(rows), | |
| "inline_truncated": False, | |
| } | |
| if diagnostics: | |
| payload["diagnostics"] = diagnostics | |
| return payload_response( | |
| tool=tool, | |
| payload=payload, | |
| message=message, | |
| ) | |
| def csv_result_payload_response( | |
| tool: str, | |
| columns: list[str], | |
| rows: Iterable[str], | |
| *, | |
| result_store: FileResultStore, | |
| source: dict[str, Any] | None = None, | |
| message: str | None = None, | |
| diagnostics: list[dict[str, Any]] | None = None, | |
| diagnostics_factory: Callable[[int], list[dict[str, Any]] | None] | None = None, | |
| owner_session_id: str | None = None, | |
| max_inline_rows: int | None = None, | |
| page_size_rows: int | None = None, | |
| ) -> list[TextContent]: | |
| stored = result_store.write_csv_result( | |
| tool=tool, | |
| columns=columns, | |
| rows=rows, | |
| source=source, | |
| owner_session_id=owner_session_id, | |
| preview_rows=max_inline_rows, | |
| page_size_rows=page_size_rows, | |
| ) | |
| preview_rows = stored.preview_rows | |
| inline_truncated = stored.row_count > len(preview_rows) | |
| payload: dict[str, Any] = { | |
| "format": "csv", | |
| "columns": columns, | |
| "rows": preview_rows, | |
| "text": _csv_text(columns, preview_rows), | |
| "row_count": stored.row_count, | |
| "inline_row_count": len(preview_rows), | |
| "inline_truncated": inline_truncated, | |
| "result_id": stored.result_id, | |
| "owner_session_id": stored.owner_session_id, | |
| "next_cursor": str(len(preview_rows)) if inline_truncated else None, | |
| "result_store": { | |
| "type": "file", | |
| "result_id": stored.result_id, | |
| "owner_session_id": stored.owner_session_id, | |
| "page_tool": "read_result_page", | |
| "batch_page_tool": "read_result_pages", | |
| "page_size_rows": stored.page_size_rows, | |
| "manifest_path": stored.manifest_path, | |
| "shard_count": stored.shard_count, | |
| "byte_count": stored.byte_count, | |
| }, | |
| } | |
| combined_diagnostics = list(diagnostics or []) | |
| if diagnostics_factory is not None: | |
| combined_diagnostics.extend(diagnostics_factory(stored.row_count) or []) | |
| if combined_diagnostics: | |
| payload["diagnostics"] = combined_diagnostics | |
| return payload_response(tool=tool, payload=payload, message=message) | |
| def sql_success_response(tool: str, sql: str) -> list[TextContent]: | |
| return payload_response( | |
| tool=tool, | |
| payload={"sql": sql, "result": "success"}, | |
| message="SQL executed successfully.", | |
| ) |