blob: 3e51e52eded82a2cc73938a91a3155f589d58f57 [file]
#
# 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.",
)