blob: 63940a5ca088e4e7dc97080be378cc9989d1e814 [file]
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,
)