| from __future__ import annotations | |
| import copy | |
| from contextlib import contextmanager | |
| import os | |
| from pathlib import Path | |
| import time | |
| from typing import Any, Iterator, Mapping | |
| from iotdb.Session import Session | |
| from iotdb.table_session import TableSession, TableSessionConfig | |
| from iotdb_mcp_server.config import Config | |
| from iotdb_mcp_server.session_manager import ( | |
| create_table_session_pool, | |
| create_tree_session_pool, | |
| ) | |
| from iotdb_mcp_server.target_registry import IoTDBTargetRegistry | |
| from iotdb_mcp_server.services.json_response import ( | |
| reset_iotdb_target_context, | |
| set_iotdb_target_context, | |
| ) | |
| TargetSelector = str | Mapping[str, Any] | None | |
| _AINODE_PROBE_CACHE: dict[str, dict[str, Any]] = {} | |
| _DEFAULT_AINODE_PROBE_TTL_SECONDS = 10.0 | |
| def _copy_target_fields(config: Config, source: Config) -> None: | |
| for field_name in ( | |
| "host", | |
| "port", | |
| "user", | |
| "password", | |
| "database", | |
| "sql_dialect", | |
| "timezone", | |
| "export_path", | |
| "target_id", | |
| "display_name", | |
| "target_kind", | |
| "node_urls", | |
| "iotdb_home", | |
| "use_ssl", | |
| "ca_certs", | |
| "connection_timeout_in_ms", | |
| "enable_redirection", | |
| "enable_compression", | |
| "fetch_size", | |
| "max_retry", | |
| "max_pool_size", | |
| "tree_wait_timeout_in_ms", | |
| "table_wait_timeout_in_ms", | |
| "target_fingerprint", | |
| "policy", | |
| "last_known_good_credential", | |
| "verified_at", | |
| ): | |
| setattr(config, field_name, getattr(source, field_name)) | |
| def apply_target_registry( | |
| config: Config, | |
| registry: IoTDBTargetRegistry, | |
| *, | |
| active_target_id: str | None = None, | |
| targets_file_mtime_ns: int | None = None, | |
| close_pools: bool = True, | |
| ) -> Config: | |
| if not registry.list_targets(include_secret=False): | |
| config.target_registry = registry | |
| config.target_id = "" | |
| config.display_name = "" | |
| config.target_kind = "" | |
| config.host = "" | |
| config.port = 0 | |
| config.node_urls = () | |
| config.user = "" | |
| config.password = "" | |
| config.database = "" | |
| config.target_fingerprint = "" | |
| config.policy = {} | |
| config.last_known_good_credential = {} | |
| config.verified_at = "" | |
| if config.session_manager is not None: | |
| config.session_manager.update_registry(registry, close_pools=close_pools) | |
| return config | |
| active_target = registry.resolve(active_target_id or registry.default_target_id) | |
| selected = Config.from_target( | |
| active_target, | |
| target_registry=registry, | |
| session_manager=config.session_manager, | |
| ) | |
| _copy_target_fields(config, selected) | |
| config.target_registry = registry | |
| if targets_file_mtime_ns is not None: | |
| config.targets_file_mtime_ns = targets_file_mtime_ns | |
| if config.session_manager is not None: | |
| update = getattr(config.session_manager, "update_registry", None) | |
| if update is not None: | |
| update(registry, close_pools=close_pools) | |
| else: | |
| config.session_manager.registry = registry | |
| return config | |
| def maybe_reload_target_registry(config: Config) -> bool: | |
| targets_file = (config.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")).strip() | |
| if not targets_file: | |
| return False | |
| path = Path(targets_file).expanduser() | |
| try: | |
| mtime_ns = path.stat().st_mtime_ns | |
| except OSError: | |
| return False | |
| if config.targets_file_mtime_ns == mtime_ns: | |
| return False | |
| registry = IoTDBTargetRegistry.from_env( | |
| targets_file=str(path), | |
| targets_json=config.targets_json or os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "") or None, | |
| ) | |
| apply_target_registry( | |
| config, | |
| registry, | |
| targets_file_mtime_ns=mtime_ns, | |
| close_pools=True, | |
| ) | |
| return True | |
| def _string_fields(row: Any) -> list[str]: | |
| fields = row.get_fields() if hasattr(row, "get_fields") else row | |
| if isinstance(fields, (list, tuple)): | |
| return [str(field) for field in fields] | |
| return [str(fields)] | |
| def _row_dict(columns: list[str], row: list[str]) -> dict[str, str] | dict[str, list[str]]: | |
| if len(columns) == len(row): | |
| return dict(zip(columns, row)) | |
| return {"values": row} | |
| def _has_running_ainode(columns: list[str], rows: list[list[str]]) -> bool: | |
| status_index = next( | |
| (index for index, column in enumerate(columns) if column.strip().lower() == "status"), | |
| None, | |
| ) | |
| if status_index is not None: | |
| return any( | |
| len(row) > status_index and row[status_index].strip().lower() == "running" | |
| for row in rows | |
| ) | |
| return any(any(value.strip().lower() == "running" for value in row) for row in rows) | |
| def _read_dataset(res: Any) -> tuple[list[str], list[list[str]]]: | |
| columns = [str(column) for column in res.get_column_names()] | |
| rows: list[list[str]] = [] | |
| while res.has_next(): | |
| rows.append(_string_fields(res.next())) | |
| return columns, rows | |
| def _ainode_probe_ttl_seconds() -> float: | |
| raw = os.getenv("TIMESEEK_AINODE_PROBE_TTL_SECONDS", "").strip() | |
| if not raw: | |
| return _DEFAULT_AINODE_PROBE_TTL_SECONDS | |
| try: | |
| return max(0.0, float(raw)) | |
| except ValueError: | |
| return _DEFAULT_AINODE_PROBE_TTL_SECONDS | |
| def _ainode_probe_cache_key(config: Config) -> str: | |
| try: | |
| return config.to_target().auth_fingerprint() | |
| except Exception: | |
| return config.target_fingerprint or f"{config.target_id}:{config.host}:{config.port}" | |
| def _cached_ainode_probe_result( | |
| entry: dict[str, Any], age_seconds: float, ttl_seconds: float | |
| ) -> dict[str, Any]: | |
| result = copy.deepcopy(entry["result"]) | |
| source = result.get("ainode_availability_source") | |
| if isinstance(source, dict): | |
| source["cached"] = True | |
| source["cache_age_ms"] = int(max(0.0, age_seconds) * 1000) | |
| source["cache_ttl_seconds"] = ttl_seconds | |
| return result | |
| def _last_success_for_stale_result( | |
| cache_key: str, error_source: dict[str, Any] | |
| ) -> dict[str, Any] | None: | |
| cached = _AINODE_PROBE_CACHE.get(cache_key) | |
| if not cached: | |
| return None | |
| age_seconds = time.monotonic() - float(cached["monotonic"]) | |
| cached_result = cached["result"] | |
| source = { | |
| **error_source, | |
| "stale_from_last_success": True, | |
| "cache_age_ms": int(max(0.0, age_seconds) * 1000), | |
| "last_success": copy.deepcopy( | |
| cached_result.get("ainode_availability_source") | |
| ), | |
| } | |
| return { | |
| "ainode_available": cached_result.get("ainode_available", "unknown"), | |
| "ainode_availability_source": source, | |
| } | |
| def _open_ainode_probe_session(config: Config): | |
| """Open a short-lived direct session for AINode discovery. | |
| This intentionally avoids the shared SessionPool used by business tools. | |
| The IoTDB Python SessionPool requires `put_back()`, while many call sites | |
| close raw sessions; a one-session probe pool can therefore time out under | |
| repeated response annotation. A direct session keeps probe health isolated. | |
| """ | |
| if config.sql_dialect == "tree": | |
| session = Session.init_from_node_urls( | |
| list(config.node_urls), | |
| config.user, | |
| config.password, | |
| config.fetch_size, | |
| config.timezone, | |
| enable_redirection=config.enable_redirection, | |
| use_ssl=config.use_ssl, | |
| ca_certs=config.ca_certs or None, | |
| connection_timeout_in_ms=config.connection_timeout_in_ms, | |
| ) | |
| session.sql_dialect = "tree" | |
| session.database = config.database | |
| session.open(config.enable_compression) | |
| return session | |
| return TableSession( | |
| TableSessionConfig( | |
| node_urls=list(config.node_urls), | |
| username=config.user, | |
| password=config.password, | |
| database=config.database or None, | |
| fetch_size=config.fetch_size, | |
| time_zone=config.timezone, | |
| enable_redirection=config.enable_redirection, | |
| enable_compression=config.enable_compression, | |
| use_ssl=config.use_ssl, | |
| ca_certs=config.ca_certs or None, | |
| connection_timeout_in_ms=config.connection_timeout_in_ms, | |
| ) | |
| ) | |
| def _probe_ainode_availability(config: Config) -> dict[str, Any]: | |
| session = None | |
| cache_key = _ainode_probe_cache_key(config) | |
| ttl_seconds = _ainode_probe_ttl_seconds() | |
| cached = _AINODE_PROBE_CACHE.get(cache_key) | |
| if cached and ttl_seconds > 0: | |
| age_seconds = time.monotonic() - float(cached["monotonic"]) | |
| if age_seconds <= ttl_seconds: | |
| return _cached_ainode_probe_result(cached, age_seconds, ttl_seconds) | |
| try: | |
| session = _open_ainode_probe_session(config) | |
| res = session.execute_query_statement("SHOW AINODES") | |
| columns, raw_rows = _read_dataset(res) | |
| running_count = sum( | |
| 1 | |
| for row in raw_rows | |
| if _has_running_ainode(columns, [row]) | |
| ) | |
| result = { | |
| "ainode_available": "yes" if running_count > 0 else "no", | |
| "ainode_availability_source": { | |
| "method": "SHOW AINODES", | |
| "ok": True, | |
| "columns": columns, | |
| "rows": [_row_dict(columns, row) for row in raw_rows], | |
| "row_count": len(raw_rows), | |
| "running_count": running_count, | |
| }, | |
| } | |
| _AINODE_PROBE_CACHE[cache_key] = { | |
| "monotonic": time.monotonic(), | |
| "result": copy.deepcopy(result), | |
| } | |
| return result | |
| except Exception as exc: | |
| error_source = { | |
| "method": "SHOW AINODES", | |
| "ok": False, | |
| "error_type": type(exc).__name__, | |
| "error": str(exc), | |
| } | |
| stale = _last_success_for_stale_result(cache_key, error_source) | |
| if stale: | |
| return stale | |
| return { | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": error_source, | |
| } | |
| finally: | |
| if session is not None: | |
| close = getattr(session, "close", None) | |
| if close is not None: | |
| close() | |
| def _target_payload(config: Config) -> dict[str, Any]: | |
| payload = { | |
| "target_id": config.target_id, | |
| "display_name": config.display_name, | |
| "kind": config.target_kind, | |
| "host": config.host, | |
| "port": config.port, | |
| "node_urls": list(config.node_urls), | |
| "database": config.database, | |
| "sql_dialect": config.sql_dialect, | |
| "timezone": config.timezone, | |
| "policy": dict(config.policy), | |
| "fingerprint": config.target_fingerprint, | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": {"method": "not_probed", "ok": None}, | |
| } | |
| return payload | |
| def select_target_config( | |
| config: Config, | |
| target_id: str | None = None, | |
| target: TargetSelector = None, | |
| required_sql_dialect: str | None = None, | |
| tool_name: str | None = None, | |
| ) -> Config: | |
| maybe_reload_target_registry(config) | |
| registry = config.target_registry | |
| if registry is None: | |
| if target_id and target_id != config.target_id: | |
| raise KeyError( | |
| f"IoTDB target '{target_id}' is not registered in this MCP server." | |
| ) | |
| if isinstance(target, str) and target != config.target_id: | |
| raise KeyError( | |
| f"IoTDB target '{target}' is not registered in this MCP server." | |
| ) | |
| if isinstance(target, Mapping): | |
| requested_id = target.get("target_id") or target.get("id") or target.get("name") | |
| if requested_id and str(requested_id) != config.target_id: | |
| raise KeyError( | |
| f"IoTDB target '{requested_id}' is not registered in this MCP server." | |
| ) | |
| candidate = _target_payload(config) | |
| candidate["target_kind"] = candidate["kind"] | |
| candidate["dialect"] = candidate["sql_dialect"] | |
| for key in ( | |
| "display_name", | |
| "kind", | |
| "target_kind", | |
| "host", | |
| "port", | |
| "database", | |
| "sql_dialect", | |
| "dialect", | |
| ): | |
| expected = target.get(key) | |
| if expected is not None and str(candidate[key]) != str(expected): | |
| raise KeyError( | |
| "IoTDB target selector does not match the active target." | |
| ) | |
| endpoint = target.get("node_url") or target.get("endpoint") | |
| if endpoint and str(endpoint).strip() not in config.node_urls: | |
| raise KeyError( | |
| "IoTDB target selector does not match the active target." | |
| ) | |
| raw_node_urls = ( | |
| target.get("node_urls") | |
| or target.get("endpoints") | |
| or target.get("nodes") | |
| ) | |
| if raw_node_urls is not None: | |
| if isinstance(raw_node_urls, (list, tuple)): | |
| requested_node_urls = [ | |
| str(item).strip() for item in raw_node_urls if str(item).strip() | |
| ] | |
| else: | |
| requested_node_urls = [ | |
| item.strip() | |
| for item in str(raw_node_urls).split(",") | |
| if item.strip() | |
| ] | |
| if not all(item in config.node_urls for item in requested_node_urls): | |
| raise KeyError( | |
| "IoTDB target selector does not match the active target." | |
| ) | |
| selected = config | |
| else: | |
| selected_target = registry.resolve_selector( | |
| target, | |
| target_id=target_id or (None if target is not None else config.target_id), | |
| ) | |
| selected = Config.from_target( | |
| selected_target, | |
| target_registry=config.target_registry, | |
| session_manager=config.session_manager, | |
| ) | |
| # Nested pool helpers select again. Preserve the reload watermark so a | |
| # derived explicit-target config is not reset to the registry default. | |
| selected.targets_file = config.targets_file | |
| selected.targets_json = config.targets_json | |
| selected.targets_file_mtime_ns = config.targets_file_mtime_ns | |
| if required_sql_dialect and selected.sql_dialect != required_sql_dialect: | |
| owner = f"{tool_name} " if tool_name else "" | |
| raise ValueError( | |
| f"{owner}requires {required_sql_dialect} SQL dialect, but IoTDB target " | |
| f"'{selected.target_id}' uses {selected.sql_dialect}." | |
| ) | |
| return selected | |
| def tree_session_pool( | |
| config: Config, | |
| target_id: str | None = None, | |
| target: TargetSelector = None, | |
| max_pool_size: int | None = None, | |
| wait_timeout_in_ms: int | None = None, | |
| tool_name: str | None = None, | |
| ): | |
| selected = select_target_config( | |
| config, | |
| target_id=target_id, | |
| target=target, | |
| required_sql_dialect="tree", | |
| tool_name=tool_name, | |
| ) | |
| if selected.session_manager is not None: | |
| return selected, selected.session_manager.tree_pool( | |
| selected.target_id, | |
| max_pool_size=max_pool_size, | |
| wait_timeout_in_ms=wait_timeout_in_ms, | |
| ) | |
| return selected, create_tree_session_pool( | |
| selected, | |
| max_pool_size=max_pool_size, | |
| wait_timeout_in_ms=wait_timeout_in_ms, | |
| ) | |
| def table_session_pool( | |
| config: Config, | |
| target_id: str | None = None, | |
| target: TargetSelector = None, | |
| max_pool_size: int | None = None, | |
| database: str | None = None, | |
| wait_timeout_in_ms: int | None = None, | |
| tool_name: str | None = None, | |
| ): | |
| selected = select_target_config( | |
| config, | |
| target_id=target_id, | |
| target=target, | |
| required_sql_dialect="table", | |
| tool_name=tool_name, | |
| ) | |
| if selected.session_manager is not None: | |
| return selected, selected.session_manager.table_pool( | |
| selected.target_id, | |
| max_pool_size=max_pool_size, | |
| database=database, | |
| wait_timeout_in_ms=wait_timeout_in_ms, | |
| ) | |
| return selected, create_table_session_pool( | |
| selected, | |
| max_pool_size=max_pool_size, | |
| database=database, | |
| wait_timeout_in_ms=wait_timeout_in_ms, | |
| ) | |
| @contextmanager | |
| def iotdb_target_response_context(config: Config) -> Iterator[None]: | |
| token = set_iotdb_target_context(_target_payload(config)) | |
| try: | |
| yield | |
| finally: | |
| reset_iotdb_target_context(token) |