| # | |
| # 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 logging | |
| import json | |
| import os | |
| from pathlib import Path | |
| import tempfile | |
| from mcp.types import TextContent | |
| from iotdb_mcp_server.config import Config | |
| from iotdb_mcp_server.services.json_response import payload_response | |
| from iotdb_mcp_server.services.target_selection import ( | |
| apply_target_registry, | |
| iotdb_target_response_context, | |
| maybe_reload_target_registry, | |
| select_target_config, | |
| ) | |
| from iotdb_mcp_server.target_registry import ( | |
| IoTDBTargetRegistry, | |
| mark_target_last_known_good, | |
| target_from_mapping, | |
| ) | |
| from iotdb_mcp_server.target_lifecycle import ( | |
| IoTDBTargetCandidateStore, | |
| candidate_target, | |
| same_target_identity, | |
| verify_target_once, | |
| ) | |
| def _registry(config: Config) -> IoTDBTargetRegistry: | |
| maybe_reload_target_registry(config) | |
| if config.target_registry is not None: | |
| return config.target_registry | |
| registry = IoTDBTargetRegistry({}) | |
| config.target_registry = registry | |
| return registry | |
| def _targets_file(config: Config, targets_file: str | None = None) -> Path: | |
| resolved = ( | |
| targets_file | |
| or config.targets_file | |
| or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "") | |
| ).strip() | |
| if not resolved: | |
| raise ValueError( | |
| "No IoTDB targets file is configured. Pass targets_file or set " | |
| "TIMESEEK_IOTDB_TARGETS_FILE to persist dynamic target changes." | |
| ) | |
| return Path(resolved).expanduser() | |
| def _persist_registry( | |
| config: Config, registry: IoTDBTargetRegistry, targets_file: str | None | |
| ) -> None: | |
| path = _targets_file(config, targets_file) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| payload = ( | |
| json.dumps(registry.as_dict(include_secret=True), ensure_ascii=False, indent=2) | |
| + "\n" | |
| ) | |
| temporary_path: Path | None = None | |
| try: | |
| with tempfile.NamedTemporaryFile( | |
| mode="w", | |
| encoding="utf-8", | |
| dir=path.parent, | |
| prefix=f".{path.name}.", | |
| suffix=".tmp", | |
| delete=False, | |
| ) as handle: | |
| handle.write(payload) | |
| handle.flush() | |
| os.fsync(handle.fileno()) | |
| temporary_path = Path(handle.name) | |
| temporary_path.chmod(0o600) | |
| os.replace(temporary_path, path) | |
| finally: | |
| if temporary_path is not None and temporary_path.exists(): | |
| temporary_path.unlink() | |
| try: | |
| path.chmod(0o600) | |
| except OSError: | |
| pass | |
| config.targets_file = str(path) | |
| try: | |
| config.targets_file_mtime_ns = path.stat().st_mtime_ns | |
| except OSError: | |
| config.targets_file_mtime_ns = None | |
| def _targets_payload(config: Config) -> dict[str, object]: | |
| registry = _registry(config) | |
| return { | |
| "default_target_id": registry.default_target_id, | |
| "active_target_id": config.target_id or None, | |
| "targets": registry.list_targets(include_secret=False), | |
| "selector_fields": [ | |
| "target_id", | |
| "id", | |
| "display_name", | |
| "kind", | |
| "host", | |
| "port", | |
| "database", | |
| "sql_dialect", | |
| "user", | |
| "node_url", | |
| "node_urls", | |
| ], | |
| "dynamic_tools": [ | |
| "prepare_iotdb_target", | |
| "connect_iotdb_target", | |
| "register_iotdb_target", | |
| "remove_iotdb_target", | |
| "set_default_iotdb_target", | |
| "reload_iotdb_targets", | |
| ], | |
| "targets_file": config.targets_file | |
| or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", ""), | |
| } | |
| def register_target_tools(mcp, config: Config, logger: logging.Logger) -> None: | |
| """Register IoTDB target discovery tools.""" | |
| candidate_store = IoTDBTargetCandidateStore() | |
| def publish_target( | |
| target, | |
| *, | |
| set_default: bool, | |
| persist: bool | None, | |
| targets_file: str | None, | |
| ): | |
| registry = _registry(config) | |
| updated = registry | |
| duplicate_ids = [] | |
| for item in registry.list_targets(include_secret=True): | |
| existing = target_from_mapping(item) | |
| if existing.target_id != target.target_id and same_target_identity( | |
| existing, target | |
| ): | |
| duplicate_ids.append(existing.target_id) | |
| for duplicate_id in duplicate_ids: | |
| updated = updated.without_target(duplicate_id) | |
| make_default = ( | |
| set_default | |
| or updated.default_target_id is None | |
| or registry.default_target_id in duplicate_ids | |
| ) | |
| updated = updated.with_target( | |
| target, | |
| default_target_id=( | |
| target.target_id if make_default else updated.default_target_id | |
| ), | |
| ) | |
| persist_resolved = ( | |
| persist | |
| if persist is not None | |
| else bool( | |
| targets_file | |
| or config.targets_file | |
| or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "") | |
| ) | |
| ) | |
| if persist_resolved: | |
| _persist_registry(config, updated, targets_file) | |
| apply_target_registry( | |
| config, | |
| updated, | |
| active_target_id=( | |
| target.target_id if make_default else updated.default_target_id | |
| ), | |
| close_pools=True, | |
| ) | |
| return updated, duplicate_ids, persist_resolved | |
| def evict_on_connection_error( | |
| target_id: str, | |
| error: BaseException, | |
| updated: IoTDBTargetRegistry, | |
| ) -> None: | |
| apply_target_registry( | |
| config, | |
| updated, | |
| active_target_id=updated.default_target_id, | |
| close_pools=False, | |
| ) | |
| if config.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", ""): | |
| try: | |
| _persist_registry(config, updated, None) | |
| except Exception as persist_error: | |
| logger.error( | |
| "Removed failed IoTDB target %s in memory but could not persist: %s", | |
| target_id, | |
| persist_error, | |
| ) | |
| logger.error( | |
| "Removed IoTDB target %s after connection error %s: %s", | |
| target_id, | |
| type(error).__name__, | |
| error, | |
| ) | |
| if config.session_manager is not None: | |
| config.session_manager.set_connection_error_callback(evict_on_connection_error) | |
| @mcp.tool() | |
| async def list_iotdb_targets() -> list[TextContent]: | |
| """List registered IoTDB targets with redacted credentials.""" | |
| return payload_response( | |
| "list_iotdb_targets", | |
| _targets_payload(config), | |
| message="Registered IoTDB targets.", | |
| ) | |
| @mcp.tool() | |
| async def describe_iotdb_target( | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Describe one IoTDB target selected by id or selector fields.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| with iotdb_target_response_context(selected_config): | |
| return payload_response( | |
| "describe_iotdb_target", | |
| selected_config.to_target().as_dict(include_secret=False), | |
| message="IoTDB target description.", | |
| ) | |
| @mcp.tool() | |
| async def prepare_iotdb_target( | |
| target_template: dict[str, object], | |
| user_confirmed_retry: bool = False, | |
| ) -> list[TextContent]: | |
| """Create a credential-free, non-routable target candidate. | |
| A previous failed endpoint requires an explicit user-requested retry. | |
| Templates are memory-only and never appear in list_iotdb_targets. | |
| """ | |
| candidate = candidate_store.prepare( | |
| target_template, | |
| user_confirmed_retry=user_confirmed_retry, | |
| ) | |
| return payload_response( | |
| "prepare_iotdb_target", | |
| candidate.as_dict(), | |
| message="IoTDB target candidate prepared; credentials are still required.", | |
| ) | |
| @mcp.tool() | |
| async def connect_iotdb_target( | |
| candidate_id: str, | |
| username: str, | |
| password: str, | |
| set_default: bool = True, | |
| persist: bool | None = None, | |
| targets_file: str | None = None, | |
| ) -> list[TextContent]: | |
| """Authenticate once and publish only after success. | |
| When a targets file is configured, the verified credential is persisted | |
| by default so MCP and iotdb-target-cli read the same target state. | |
| """ | |
| candidate = candidate_store.consume(candidate_id) | |
| target = candidate_target(candidate, user=username, password=password) | |
| try: | |
| verified = verify_target_once(target) | |
| except Exception as exc: | |
| candidate_store.record_failure(candidate) | |
| logger.error( | |
| "IoTDB candidate %s was deleted after connection failure: %s", | |
| candidate_id, | |
| exc, | |
| ) | |
| raise ConnectionError( | |
| "IoTDB connection failed; the one-time candidate was deleted. " | |
| "Do not retry until the user explicitly requests reconnection. " | |
| f"Cause: {type(exc).__name__}: {exc}" | |
| ) from None | |
| updated, duplicate_ids, persisted = publish_target( | |
| verified, | |
| set_default=set_default, | |
| persist=persist, | |
| targets_file=targets_file, | |
| ) | |
| return payload_response( | |
| "connect_iotdb_target", | |
| { | |
| "registered_target": verified.as_dict(include_secret=False), | |
| "replaced_target_ids": duplicate_ids, | |
| "registry": _targets_payload(config), | |
| "persisted": persisted, | |
| }, | |
| message="IoTDB target authenticated and registered.", | |
| ) | |
| @mcp.tool() | |
| async def register_iotdb_target( | |
| target: dict[str, object], | |
| set_default: bool = False, | |
| persist: bool | None = None, | |
| targets_file: str | None = None, | |
| user_confirmed_retry: bool = False, | |
| ) -> list[TextContent]: | |
| """Compatibility entry point that verifies credentials before registration.""" | |
| if not isinstance(target, dict): | |
| raise ValueError("target must be an object.") | |
| username = target.get("user", target.get("username")) | |
| if username is None or "password" not in target: | |
| raise ValueError( | |
| "register_iotdb_target requires user and an explicitly supplied password. " | |
| "When credentials are unavailable, call prepare_iotdb_target and ask the user." | |
| ) | |
| template = { | |
| key: value | |
| for key, value in target.items() | |
| if key | |
| not in { | |
| "user", | |
| "username", | |
| "password", | |
| "last_known_good_credential", | |
| "verified_at", | |
| } | |
| } | |
| candidate = candidate_store.prepare( | |
| template, | |
| user_confirmed_retry=user_confirmed_retry, | |
| ) | |
| consumed = candidate_store.consume(candidate.candidate_id) | |
| parsed_target = candidate_target( | |
| consumed, | |
| user=str(username), | |
| password="" if target["password"] is None else str(target["password"]), | |
| ) | |
| try: | |
| verified = verify_target_once(parsed_target) | |
| except Exception as exc: | |
| candidate_store.record_failure(consumed) | |
| raise ConnectionError( | |
| "IoTDB connection failed; no target was registered. Do not retry " | |
| "until the user explicitly requests reconnection. " | |
| f"Cause: {type(exc).__name__}: {exc}" | |
| ) from None | |
| updated, duplicate_ids, persisted = publish_target( | |
| verified, | |
| set_default=set_default, | |
| persist=persist, | |
| targets_file=targets_file, | |
| ) | |
| logger.info("Authenticated and registered IoTDB target %s", verified.target_id) | |
| return payload_response( | |
| "register_iotdb_target", | |
| { | |
| "registered_target": verified.as_dict(include_secret=False), | |
| "replaced_target_ids": duplicate_ids, | |
| "registry": _targets_payload(config), | |
| "persisted": persisted, | |
| }, | |
| message="IoTDB target authenticated and registered.", | |
| ) | |
| @mcp.tool() | |
| async def remember_iotdb_target_credential( | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| persist: bool = False, | |
| targets_file: str | None = None, | |
| ) -> list[TextContent]: | |
| """Record the selected target's current credential as last-known-good. | |
| Call this only after a connection/query has succeeded with the selected | |
| target. This tool does not test login and does not try fallback | |
| passwords. | |
| """ | |
| registry = _registry(config) | |
| selected_target = registry.resolve_selector( | |
| target, | |
| target_id=target_id or (None if target is not None else config.target_id), | |
| ) | |
| updated_target = mark_target_last_known_good(selected_target) | |
| updated = registry.with_target(updated_target) | |
| apply_target_registry( | |
| config, | |
| updated, | |
| active_target_id=( | |
| config.target_id | |
| if config.target_id | |
| in { | |
| item["target_id"] | |
| for item in updated.list_targets(include_secret=False) | |
| } | |
| else updated.default_target_id | |
| ), | |
| close_pools=False, | |
| ) | |
| if persist: | |
| _persist_registry(config, updated, targets_file) | |
| logger.info( | |
| "Remembered IoTDB target credential for %s", | |
| updated_target.target_id, | |
| ) | |
| return payload_response( | |
| "remember_iotdb_target_credential", | |
| { | |
| "remembered_target": updated_target.as_dict(include_secret=False), | |
| "registry": _targets_payload(config), | |
| "persisted": persist, | |
| }, | |
| message="IoTDB target last-known-good credential recorded.", | |
| ) | |
| @mcp.tool() | |
| async def remove_iotdb_target( | |
| target_id: str, | |
| persist: bool = False, | |
| targets_file: str | None = None, | |
| ) -> list[TextContent]: | |
| """Remove an IoTDB target from this running MCP process.""" | |
| registry = _registry(config) | |
| updated = registry.without_target(target_id) | |
| apply_target_registry( | |
| config, | |
| updated, | |
| active_target_id=updated.default_target_id, | |
| close_pools=True, | |
| ) | |
| if persist: | |
| _persist_registry(config, updated, targets_file) | |
| logger.info("Removed IoTDB target %s", target_id) | |
| return payload_response( | |
| "remove_iotdb_target", | |
| { | |
| "removed_target_id": target_id, | |
| "registry": _targets_payload(config), | |
| "persisted": persist, | |
| }, | |
| message="IoTDB target removed.", | |
| ) | |
| @mcp.tool() | |
| async def set_default_iotdb_target( | |
| target_id: str, | |
| persist: bool = False, | |
| targets_file: str | None = None, | |
| ) -> list[TextContent]: | |
| """Set the default IoTDB target for this running MCP process.""" | |
| registry = _registry(config) | |
| updated = registry.with_default(target_id) | |
| apply_target_registry( | |
| config, | |
| updated, | |
| active_target_id=target_id, | |
| close_pools=False, | |
| ) | |
| if persist: | |
| _persist_registry(config, updated, targets_file) | |
| logger.info("Set default IoTDB target %s", target_id) | |
| return payload_response( | |
| "set_default_iotdb_target", | |
| { | |
| "default_target_id": target_id, | |
| "registry": _targets_payload(config), | |
| "persisted": persist, | |
| }, | |
| message="Default IoTDB target updated.", | |
| ) | |
| @mcp.tool() | |
| async def reload_iotdb_targets( | |
| targets_file: str | None = None, | |
| targets_json: str | None = None, | |
| ) -> list[TextContent]: | |
| """Reload IoTDB targets from env, a targets file, or an explicit JSON string.""" | |
| resolved_file = ( | |
| targets_file | |
| or config.targets_file | |
| or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "") | |
| or None | |
| ) | |
| resolved_json = ( | |
| targets_json | |
| or config.targets_json | |
| or os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "") | |
| or None | |
| ) | |
| registry = IoTDBTargetRegistry.from_env( | |
| targets_file=resolved_file, | |
| targets_json=resolved_json, | |
| ) | |
| mtime_ns = None | |
| if resolved_file: | |
| try: | |
| mtime_ns = Path(resolved_file).expanduser().stat().st_mtime_ns | |
| except OSError: | |
| mtime_ns = None | |
| config.targets_file = resolved_file | |
| if resolved_json: | |
| config.targets_json = resolved_json | |
| apply_target_registry( | |
| config, | |
| registry, | |
| targets_file_mtime_ns=mtime_ns, | |
| close_pools=True, | |
| ) | |
| logger.info("Reloaded IoTDB targets") | |
| return payload_response( | |
| "reload_iotdb_targets", | |
| _targets_payload(config), | |
| message="IoTDB targets reloaded.", | |
| ) |