| from __future__ import annotations |
| |
| from dataclasses import dataclass |
| from datetime import datetime, timedelta, timezone |
| import hashlib |
| import json |
| import threading |
| import uuid |
| from typing import Any, Mapping |
| |
| from iotdb.Session import Session |
| |
| from iotdb_mcp_server.target_registry import ( |
| IoTDBTarget, |
| mark_target_last_known_good, |
| target_from_mapping, |
| ) |
| |
| |
| _SECRET_FIELDS = { |
| "user", |
| "username", |
| "password", |
| "IOTDB_USER", |
| "IOTDB_PASSWORD", |
| "last_known_good_credential", |
| } |
| |
| |
| def _now() -> datetime: |
| return datetime.now(timezone.utc) |
| |
| |
| def _iso(value: datetime) -> str: |
| return value.isoformat() |
| |
| |
| def _endpoint_key(spec: Mapping[str, Any]) -> str: |
| host = str(spec.get("host") or "127.0.0.1").strip().lower() |
| port = int(spec.get("port") or 6667) |
| dialect = str(spec.get("sql_dialect") or "tree").strip().lower() |
| return f"{host}:{port}:{dialect}" |
| |
| |
| @dataclass(frozen=True) |
| class IoTDBTargetCandidate: |
| candidate_id: str |
| spec: dict[str, Any] |
| created_at: str |
| expires_at: str |
| |
| def as_dict(self) -> dict[str, Any]: |
| return { |
| "candidate_id": self.candidate_id, |
| "template": dict(self.spec), |
| "created_at": self.created_at, |
| "expires_at": self.expires_at, |
| "credentials_required": True, |
| "registered": False, |
| } |
| |
| |
| class IoTDBTargetCandidateStore: |
| def __init__(self, ttl_seconds: int = 900, retry_guard_seconds: int = 900) -> None: |
| self.ttl_seconds = ttl_seconds |
| self.retry_guard_seconds = retry_guard_seconds |
| self._candidates: dict[str, IoTDBTargetCandidate] = {} |
| self._failed_endpoints: dict[str, datetime] = {} |
| self._lock = threading.Lock() |
| |
| def _prune(self, now: datetime) -> None: |
| self._candidates = { |
| candidate_id: candidate |
| for candidate_id, candidate in self._candidates.items() |
| if datetime.fromisoformat(candidate.expires_at) > now |
| } |
| self._failed_endpoints = { |
| endpoint: expires_at |
| for endpoint, expires_at in self._failed_endpoints.items() |
| if expires_at > now |
| } |
| |
| def prepare( |
| self, |
| spec: Mapping[str, Any], |
| *, |
| user_confirmed_retry: bool = False, |
| ) -> IoTDBTargetCandidate: |
| if not isinstance(spec, Mapping): |
| raise ValueError("target template must be an object.") |
| secret_fields = sorted(_SECRET_FIELDS.intersection(spec)) |
| if secret_fields: |
| raise ValueError( |
| "Target templates cannot contain credentials. Pass username/password " |
| "only to connect_iotdb_target after the user provides them." |
| ) |
| clean = {str(key): value for key, value in spec.items()} |
| clean.setdefault("host", "127.0.0.1") |
| clean.setdefault("port", 6667) |
| clean.setdefault("sql_dialect", "tree") |
| # Validate endpoint and dialect without publishing a target. |
| target_from_mapping({**clean, "user": "candidate", "password": ""}) |
| |
| now = _now() |
| endpoint = _endpoint_key(clean) |
| with self._lock: |
| self._prune(now) |
| if endpoint in self._failed_endpoints and not user_confirmed_retry: |
| raise PermissionError( |
| "A previous connection attempt for this endpoint failed. " |
| "Create a new candidate only after the user explicitly requests " |
| "reconnection, then pass user_confirmed_retry=true." |
| ) |
| candidate = IoTDBTargetCandidate( |
| candidate_id=f"candidate-{uuid.uuid4().hex}", |
| spec=clean, |
| created_at=_iso(now), |
| expires_at=_iso(now + timedelta(seconds=self.ttl_seconds)), |
| ) |
| self._candidates[candidate.candidate_id] = candidate |
| return candidate |
| |
| def consume(self, candidate_id: str) -> IoTDBTargetCandidate: |
| now = _now() |
| with self._lock: |
| self._prune(now) |
| try: |
| return self._candidates.pop(candidate_id) |
| except KeyError as exc: |
| raise KeyError( |
| "IoTDB target candidate is missing, expired, or already consumed." |
| ) from exc |
| |
| def record_failure(self, candidate: IoTDBTargetCandidate) -> None: |
| with self._lock: |
| self._failed_endpoints[_endpoint_key(candidate.spec)] = _now() + timedelta( |
| seconds=self.retry_guard_seconds |
| ) |
| |
| |
| def canonical_target_id(spec: Mapping[str, Any], user: str) -> str: |
| explicit = str(spec.get("target_id") or spec.get("id") or "").strip() |
| if explicit: |
| return explicit |
| identity = { |
| "host": str(spec.get("host") or "127.0.0.1").strip().lower(), |
| "port": int(spec.get("port") or 6667), |
| "user": user.strip().lower(), |
| "sql_dialect": str(spec.get("sql_dialect") or "tree").strip().lower(), |
| } |
| digest = hashlib.sha256( |
| json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8") |
| ).hexdigest()[:16] |
| return f"iotdb-{digest}" |
| |
| |
| def candidate_target( |
| candidate: IoTDBTargetCandidate, |
| *, |
| user: str, |
| password: str, |
| ) -> IoTDBTarget: |
| if not user.strip(): |
| raise ValueError("IoTDB username cannot be empty.") |
| spec = dict(candidate.spec) |
| spec["target_id"] = canonical_target_id(spec, user) |
| spec["user"] = user |
| spec["password"] = password |
| return target_from_mapping(spec) |
| |
| |
| def verify_target_once(target: IoTDBTarget) -> IoTDBTarget: |
| """Authenticate once and complete one read-only operation before publishing.""" |
| session = Session( |
| target.host, |
| target.port, |
| target.user, |
| target.password, |
| target.fetch_size, |
| target.timezone, |
| enable_redirection=False, |
| use_ssl=target.use_ssl, |
| ca_certs=target.ca_certs or None, |
| connection_timeout_in_ms=target.connection_timeout_in_ms, |
| ) |
| session.sql_dialect = target.sql_dialect |
| session.database = target.database |
| try: |
| session.open(target.enable_compression) |
| result = session.execute_query_statement("SHOW VERSION") |
| close_result = getattr(result, "close_operation_handle", None) |
| if close_result is not None: |
| close_result() |
| finally: |
| try: |
| session.close() |
| except Exception: |
| pass |
| return mark_target_last_known_good(target, password_source="verified_connection") |
| |
| |
| def same_target_identity(left: IoTDBTarget, right: IoTDBTarget) -> bool: |
| return ( |
| left.host.strip().lower(), |
| left.port, |
| left.user.strip().lower(), |
| left.sql_dialect, |
| ) == ( |
| right.host.strip().lower(), |
| right.port, |
| right.user.strip().lower(), |
| right.sql_dialect, |
| ) |