| # |
| # 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 time |
| from typing import Any |
| |
| from mcp.types import TextContent |
| |
| from iotdb_mcp_server.config import Config |
| from iotdb_mcp_server.runtime_policy import dynamic_env_bool |
| from iotdb_mcp_server.runtime_policy import dynamic_getenv |
| from iotdb_mcp_server.runtime_policy import strict_permission_enforcement |
| from iotdb_mcp_server.services.json_response import payload_response, sql_success_response |
| from iotdb_mcp_server.services.target_selection import ( |
| iotdb_target_response_context, |
| tree_session_pool, |
| ) |
| |
| |
| def _env_bool(name: str, default: bool) -> bool: |
| return dynamic_env_bool(name, default) |
| |
| |
| def _csv_set(value: str) -> set[str]: |
| return {item.strip() for item in value.split(",") if item.strip()} |
| |
| |
| def _assert_timeseries_ddl_permission( |
| config: Config, action: str, confirm: bool = False |
| ) -> None: |
| if strict_permission_enforcement(): |
| if not _env_bool("IOTDB_ENABLE_TIMESERIES_DDL", False): |
| raise PermissionError( |
| "Timeseries DDL tools are disabled by strict MCP policy. " |
| "Set IOTDB_ENABLE_TIMESERIES_DDL=true to enable." |
| ) |
| |
| allowed_users = _csv_set(dynamic_getenv("IOTDB_TIMESERIES_DDL_ALLOWED_USERS", "root") or "root") |
| if "*" not in allowed_users and config.user not in allowed_users: |
| raise PermissionError( |
| f"Current MCP user '{config.user}' is not allowed by IOTDB_TIMESERIES_DDL_ALLOWED_USERS." |
| ) |
| |
| if ( |
| action == "DROP" |
| and _env_bool("IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM", True) |
| and not confirm |
| ): |
| raise PermissionError( |
| "DROP/DELETE TIMESERIES requires confirm=True by server policy." |
| ) |
| |
| |
| def _normalize_sql(sql: str) -> str: |
| cleaned = sql.strip() |
| if not cleaned: |
| raise ValueError("DDL SQL cannot be empty.") |
| if cleaned.endswith(";"): |
| cleaned = cleaned[:-1].strip() |
| if ";" in cleaned: |
| raise ValueError("Only a single SQL statement is allowed.") |
| return cleaned |
| |
| |
| def _ensure_prefix(sql: str, allowed_prefixes: tuple[str, ...], action_name: str) -> None: |
| upper = sql.upper() |
| if not upper.startswith(allowed_prefixes): |
| raise ValueError( |
| f"{action_name} only supports SQL starting with: {', '.join(allowed_prefixes)}" |
| ) |
| |
| |
| def _is_already_exists_error(message: str) -> bool: |
| lower = message.lower() |
| patterns = ( |
| "already exist", |
| "already_exists", |
| "path already exists", |
| "timeseries already exists", |
| ) |
| return any(p in lower for p in patterns) |
| |
| |
| def register_timeseries_tools(mcp, config: Config, logger: logging.Logger) -> None: |
| """Register tree-model timeseries-level DDL tools.""" |
| |
| @mcp.tool() |
| async def create_timeseries_ddl( |
| ddl_sql: str, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Execute tree-model CREATE TIMESERIES DDL. |
| |
| Supported prefixes: |
| - CREATE TIMESERIES |
| - CREATE ALIGNED TIMESERIES |
| """ |
| selected_config, session_pool = tree_session_pool( |
| config, |
| target_id=target_id, |
| target=target, |
| max_pool_size=100, |
| wait_timeout_in_ms=5000, |
| tool_name="create_timeseries_ddl", |
| ) |
| with iotdb_target_response_context(selected_config): |
| session = None |
| try: |
| _assert_timeseries_ddl_permission(selected_config, action="CREATE") |
| sql = _normalize_sql(ddl_sql) |
| _ensure_prefix( |
| sql, |
| ("CREATE TIMESERIES", "CREATE ALIGNED TIMESERIES"), |
| "create_timeseries_ddl", |
| ) |
| session = session_pool.get_session() |
| session.execute_non_query_statement(sql) |
| session.close() |
| return sql_success_response("create_timeseries_ddl", sql) |
| except Exception as e: |
| if session: |
| session.close() |
| logger.error(f"Failed to execute create_timeseries_ddl: {str(e)}") |
| raise |
| |
| @mcp.tool() |
| async def create_timeseries_batch_ddl( |
| ddl_list: list[str], |
| continue_on_error: bool = True, |
| skip_if_exists: bool = True, |
| max_statements: int = 1000, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Execute a batch of tree-model CREATE TIMESERIES DDL in one session pipeline. |
| |
| Supported prefixes in each statement: |
| - CREATE TIMESERIES |
| - CREATE ALIGNED TIMESERIES |
| """ |
| selected_config, session_pool = tree_session_pool( |
| config, |
| target_id=target_id, |
| target=target, |
| max_pool_size=100, |
| wait_timeout_in_ms=5000, |
| tool_name="create_timeseries_batch_ddl", |
| ) |
| with iotdb_target_response_context(selected_config): |
| session = None |
| started_at = time.perf_counter() |
| try: |
| _assert_timeseries_ddl_permission(selected_config, action="CREATE") |
| |
| if not isinstance(ddl_list, list) or len(ddl_list) == 0: |
| raise ValueError("ddl_list must be a non-empty list of SQL statements.") |
| if max_statements <= 0: |
| raise ValueError("max_statements must be greater than 0.") |
| if len(ddl_list) > max_statements: |
| raise ValueError( |
| f"ddl_list length {len(ddl_list)} exceeds max_statements={max_statements}." |
| ) |
| |
| validated_sqls: list[dict[str, Any]] = [] |
| per_statement: list[dict[str, Any]] = [] |
| failed_count = 0 |
| |
| for idx, raw_sql in enumerate(ddl_list, start=1): |
| statement_started = time.perf_counter() |
| try: |
| sql = _normalize_sql(str(raw_sql)) |
| _ensure_prefix( |
| sql, |
| ("CREATE TIMESERIES", "CREATE ALIGNED TIMESERIES"), |
| "create_timeseries_batch_ddl", |
| ) |
| validated_sqls.append({"index": idx, "sql": sql}) |
| except Exception as e: |
| failed_count += 1 |
| per_statement.append( |
| { |
| "index": idx, |
| "sql": str(raw_sql), |
| "status": "invalid_sql", |
| "elapsed_ms": round( |
| (time.perf_counter() - statement_started) * 1000, 3 |
| ), |
| "error": str(e), |
| } |
| ) |
| if not continue_on_error: |
| break |
| |
| applied_count = 0 |
| skipped_count = 0 |
| execution_aborted = False |
| |
| if validated_sqls and (failed_count == 0 or continue_on_error): |
| session = session_pool.get_session() |
| for item in validated_sqls: |
| idx = int(item["index"]) |
| sql = str(item["sql"]) |
| statement_started = time.perf_counter() |
| status = "applied" |
| error_msg = "" |
| try: |
| session.execute_non_query_statement(sql) |
| applied_count += 1 |
| except Exception as e: |
| if skip_if_exists and _is_already_exists_error(str(e)): |
| status = "skipped_existing" |
| skipped_count += 1 |
| else: |
| status = "error" |
| error_msg = str(e) |
| failed_count += 1 |
| if not continue_on_error: |
| execution_aborted = True |
| per_statement.append( |
| { |
| "index": idx, |
| "sql": sql, |
| "status": status, |
| "elapsed_ms": round( |
| (time.perf_counter() - statement_started) * 1000, 3 |
| ), |
| "error": error_msg, |
| } |
| ) |
| if execution_aborted: |
| break |
| |
| if session: |
| session.close() |
| |
| per_statement.sort(key=lambda x: x.get("index", 0)) |
| total_elapsed_ms = round((time.perf_counter() - started_at) * 1000, 3) |
| executed_count = applied_count + skipped_count + ( |
| len([x for x in per_statement if x.get("status") == "error"]) |
| ) |
| |
| return payload_response( |
| "create_timeseries_batch_ddl", |
| { |
| "result": "success" if failed_count == 0 else "partial_success", |
| "execution_mode": "single_session_pipeline", |
| "continue_on_error": continue_on_error, |
| "skip_if_exists": skip_if_exists, |
| "total_input": len(ddl_list), |
| "validated_count": len(validated_sqls), |
| "executed_count": executed_count, |
| "applied_count": applied_count, |
| "skipped_count": skipped_count, |
| "failed_count": failed_count, |
| "total_elapsed_ms": total_elapsed_ms, |
| "statements": per_statement, |
| }, |
| message="Batch CREATE TIMESERIES execution completed.", |
| ) |
| except Exception as e: |
| if session: |
| session.close() |
| logger.error(f"Failed to execute create_timeseries_batch_ddl: {str(e)}") |
| raise |
| |
| @mcp.tool() |
| async def alter_timeseries_ddl( |
| ddl_sql: str, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Execute tree-model ALTER TIMESERIES DDL.""" |
| selected_config, session_pool = tree_session_pool( |
| config, |
| target_id=target_id, |
| target=target, |
| max_pool_size=100, |
| wait_timeout_in_ms=5000, |
| tool_name="alter_timeseries_ddl", |
| ) |
| with iotdb_target_response_context(selected_config): |
| session = None |
| try: |
| _assert_timeseries_ddl_permission(selected_config, action="ALTER") |
| sql = _normalize_sql(ddl_sql) |
| _ensure_prefix(sql, ("ALTER TIMESERIES",), "alter_timeseries_ddl") |
| session = session_pool.get_session() |
| session.execute_non_query_statement(sql) |
| session.close() |
| return sql_success_response("alter_timeseries_ddl", sql) |
| except Exception as e: |
| if session: |
| session.close() |
| logger.error(f"Failed to execute alter_timeseries_ddl: {str(e)}") |
| raise |
| |
| @mcp.tool() |
| async def drop_timeseries_ddl( |
| ddl_sql: str, |
| confirm: bool = False, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Execute tree-model DROP/DELETE TIMESERIES DDL (destructive).""" |
| selected_config, session_pool = tree_session_pool( |
| config, |
| target_id=target_id, |
| target=target, |
| max_pool_size=100, |
| wait_timeout_in_ms=5000, |
| tool_name="drop_timeseries_ddl", |
| ) |
| with iotdb_target_response_context(selected_config): |
| session = None |
| try: |
| _assert_timeseries_ddl_permission( |
| selected_config, action="DROP", confirm=confirm |
| ) |
| sql = _normalize_sql(ddl_sql) |
| _ensure_prefix( |
| sql, |
| ("DROP TIMESERIES", "DELETE TIMESERIES"), |
| "drop_timeseries_ddl", |
| ) |
| session = session_pool.get_session() |
| session.execute_non_query_statement(sql) |
| session.close() |
| return sql_success_response("drop_timeseries_ddl", sql) |
| except Exception as e: |
| if session: |
| session.close() |
| logger.error(f"Failed to execute drop_timeseries_ddl: {str(e)}") |
| raise |