| # | |
| # 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 re | |
| from typing import Any | |
| from iotdb.Session import Session | |
| from iotdb.table_session import TableSession | |
| from iotdb.utils.SessionDataSet import SessionDataSet | |
| from mcp.types import TextContent | |
| from iotdb_mcp_server.config import Config | |
| from iotdb_mcp_server.result_store import result_store_from_export_path | |
| 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 ( | |
| csv_result_payload_response, | |
| payload_response, | |
| sql_success_response, | |
| ) | |
| from iotdb_mcp_server.services.target_selection import ( | |
| iotdb_target_response_context, | |
| select_target_config, | |
| table_session_pool, | |
| tree_session_pool, | |
| ) | |
| _MODEL_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{1,63}$") | |
| _INFERENCE_PATTERN = re.compile( | |
| r"^\s*CALL\s+INFERENCE\s*\((.*)\)\s*$", re.IGNORECASE | re.DOTALL | |
| ) | |
| _ALLOWED_INFERENCE_PARAMS = {"generateTime", "outputLength"} | |
| 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_model_permission( | |
| config: Config, action: str, confirm_destructive: bool = False | |
| ) -> None: | |
| if strict_permission_enforcement(): | |
| if not _env_bool("IOTDB_ENABLE_MODEL_MANAGEMENT", False): | |
| raise PermissionError( | |
| "AINode model tools are disabled by strict MCP policy. " | |
| "Set IOTDB_ENABLE_MODEL_MANAGEMENT=true to enable." | |
| ) | |
| allowed_users = _csv_set( | |
| dynamic_getenv("IOTDB_MODEL_ALLOWED_USERS", "root") or "" | |
| ) | |
| 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_MODEL_ALLOWED_USERS." | |
| ) | |
| if ( | |
| action == "DESTRUCTIVE" | |
| and _env_bool("IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM", True) | |
| and not confirm_destructive | |
| ): | |
| raise PermissionError( | |
| "Destructive model command requires confirm_destructive=True by server policy." | |
| ) | |
| def _strip_single_trailing_semicolon(sql: str, empty_message: str) -> str: | |
| cleaned = sql.strip() | |
| if not cleaned: | |
| raise ValueError(empty_message) | |
| quote: str | None = None | |
| escaped = False | |
| semicolon_indexes: list[int] = [] | |
| index = 0 | |
| while index < len(cleaned): | |
| char = cleaned[index] | |
| if escaped: | |
| escaped = False | |
| index += 1 | |
| continue | |
| if char == "\\" and quote: | |
| escaped = True | |
| index += 1 | |
| continue | |
| if quote: | |
| if char == quote: | |
| if index + 1 < len(cleaned) and cleaned[index + 1] == quote: | |
| index += 2 | |
| continue | |
| quote = None | |
| index += 1 | |
| continue | |
| if char in {"'", '"'}: | |
| quote = char | |
| elif char == ";": | |
| semicolon_indexes.append(index) | |
| index += 1 | |
| if not semicolon_indexes: | |
| return cleaned | |
| if semicolon_indexes == [len(cleaned) - 1]: | |
| return cleaned[:-1].strip() | |
| raise ValueError("Only a single SQL statement is allowed.") | |
| def _normalize_sql(sql: str) -> str: | |
| return _strip_single_trailing_semicolon(sql, "Model SQL cannot be empty.") | |
| def _normalize_for_prefix(sql: str) -> str: | |
| return re.sub(r"\s+", " ", sql).strip().upper() | |
| def _matched_prefix(sql: str, prefixes: tuple[str, ...]) -> str | None: | |
| upper = _normalize_for_prefix(sql) | |
| for prefix in prefixes: | |
| if not upper.startswith(prefix): | |
| continue | |
| if ( | |
| len(upper) == len(prefix) | |
| or upper[len(prefix)].isspace() | |
| or upper[len(prefix)] == "(" | |
| ): | |
| return prefix | |
| return None | |
| def _assert_prefix(sql: str, prefixes: tuple[str, ...], tool_name: str) -> None: | |
| if _matched_prefix(sql, prefixes): | |
| return | |
| raise ValueError( | |
| f"{tool_name} only supports SQL starting with: {', '.join(prefixes)}" | |
| ) | |
| def _split_top_level_args(value: str) -> list[str]: | |
| args: list[str] = [] | |
| start = 0 | |
| quote: str | None = None | |
| escaped = False | |
| paren_depth = 0 | |
| for index, char in enumerate(value): | |
| if escaped: | |
| escaped = False | |
| continue | |
| if char == "\\" and quote: | |
| escaped = True | |
| continue | |
| if quote: | |
| if char == quote: | |
| if index + 1 < len(value) and value[index + 1] == quote: | |
| continue | |
| quote = None | |
| continue | |
| if char in {"'", '"'}: | |
| quote = char | |
| elif char == "(": | |
| paren_depth += 1 | |
| elif char == ")": | |
| if paren_depth > 0: | |
| paren_depth -= 1 | |
| elif char == "," and paren_depth == 0: | |
| args.append(value[start:index].strip()) | |
| start = index + 1 | |
| if quote: | |
| raise ValueError("CALL INFERENCE input has an unterminated string literal.") | |
| if paren_depth: | |
| raise ValueError("CALL INFERENCE input has unbalanced parentheses.") | |
| args.append(value[start:].strip()) | |
| return args | |
| def _decode_sql_string(value: str) -> str: | |
| if len(value) < 2 or value[0] not in {"'", '"'} or value[-1] != value[0]: | |
| raise ValueError( | |
| "CALL INFERENCE second argument must be a quoted SELECT SQL string, " | |
| 'for example: "SELECT s0 FROM root.AI LIMIT 256".' | |
| ) | |
| quote = value[0] | |
| body = value[1:-1] | |
| return body.replace(quote * 2, quote) | |
| def _quote_sql_string(value: str) -> str: | |
| return '"' + value.replace('"', '""') + '"' | |
| def _assert_model_id(model_id: str) -> None: | |
| if not _MODEL_ID_PATTERN.match(model_id): | |
| raise ValueError( | |
| "model_id must be 2-64 characters, start with a letter, and contain " | |
| "only letters, numbers, or underscores." | |
| ) | |
| def _validate_inference_input_sql(input_sql: str) -> list[dict[str, Any]]: | |
| cleaned = _strip_single_trailing_semicolon( | |
| input_sql, "CALL INFERENCE input SELECT SQL cannot be empty." | |
| ) | |
| normalized = _normalize_for_prefix(cleaned) | |
| diagnostics: list[dict[str, Any]] = [] | |
| if not normalized.startswith("SELECT "): | |
| raise ValueError("CALL INFERENCE input SQL must be a SELECT query.") | |
| if re.match(r"^\s*SELECT\s+\*", cleaned, re.IGNORECASE): | |
| raise ValueError( | |
| "CALL INFERENCE input SQL must use an explicit ordered column list; " | |
| "do not use SELECT * because wildcard column order is undefined." | |
| ) | |
| if " LIMIT " not in f" {normalized} " and " WHERE " not in f" {normalized} ": | |
| diagnostics.append( | |
| { | |
| "severity": "warning", | |
| "code": "unbounded_input_sql", | |
| "message": "Prefer LIMIT or a concrete time predicate for inference input SQL.", | |
| } | |
| ) | |
| return diagnostics | |
| def _parse_inference_params(raw_params: list[str]) -> dict[str, Any]: | |
| params: dict[str, Any] = {} | |
| for raw in raw_params: | |
| if "=" not in raw: | |
| raise ValueError( | |
| "CALL INFERENCE optional arguments must use name=value syntax. " | |
| "Supported names: generateTime, outputLength." | |
| ) | |
| name, raw_value = [part.strip() for part in raw.split("=", 1)] | |
| if name not in _ALLOWED_INFERENCE_PARAMS: | |
| raise ValueError( | |
| f"Unsupported CALL INFERENCE parameter '{name}'. " | |
| "Supported parameters: generateTime, outputLength." | |
| ) | |
| if name in params: | |
| raise ValueError(f"Duplicate CALL INFERENCE parameter '{name}'.") | |
| if name == "generateTime": | |
| lowered = raw_value.lower() | |
| if lowered not in {"true", "false"}: | |
| raise ValueError("generateTime must be a boolean: true or false.") | |
| params[name] = lowered == "true" | |
| elif name == "outputLength": | |
| if not re.match(r"^[1-9][0-9]*$", raw_value): | |
| raise ValueError("outputLength must be a positive integer.") | |
| params[name] = int(raw_value) | |
| return params | |
| def _validate_inference_sql(sql: str) -> dict[str, Any]: | |
| match = _INFERENCE_PATTERN.match(sql) | |
| if not match: | |
| raise ValueError( | |
| "model_inference expects CALL INFERENCE(model_id, " | |
| '"SELECT ...", generateTime=true|false, outputLength=<positive int>).' | |
| ) | |
| args = _split_top_level_args(match.group(1)) | |
| if len(args) < 2: | |
| raise ValueError( | |
| "CALL INFERENCE requires at least model_id and input SELECT SQL." | |
| ) | |
| model_id = args[0].strip() | |
| _assert_model_id(model_id) | |
| input_sql = _decode_sql_string(args[1]) | |
| diagnostics = _validate_inference_input_sql(input_sql) | |
| params = _parse_inference_params(args[2:]) | |
| return { | |
| "model_id": model_id, | |
| "input_sql": input_sql, | |
| "parameters": params, | |
| "diagnostics": diagnostics, | |
| } | |
| def _build_inference_sql( | |
| model_id: str, | |
| input_sql: str, | |
| output_length: int = 96, | |
| generate_time: bool = False, | |
| ) -> tuple[str, dict[str, Any]]: | |
| _assert_model_id(model_id) | |
| diagnostics = _validate_inference_input_sql(input_sql) | |
| if output_length <= 0: | |
| raise ValueError("output_length must be a positive integer.") | |
| inference_sql = ( | |
| f"CALL INFERENCE({model_id}, {_quote_sql_string(input_sql)}, " | |
| f"generateTime={str(generate_time).lower()}, outputLength={output_length})" | |
| ) | |
| return inference_sql, { | |
| "model_id": model_id, | |
| "input_sql": input_sql, | |
| "parameters": { | |
| "generateTime": generate_time, | |
| "outputLength": output_length, | |
| }, | |
| "diagnostics": diagnostics, | |
| } | |
| def _format_result( | |
| res: SessionDataSet, | |
| session_or_table_session: Session | TableSession, | |
| tool_name: str, | |
| export_path: str, | |
| sql: str | None = None, | |
| owner_session_id: str | None = None, | |
| max_inline_rows: int | None = None, | |
| page_size_rows: int | None = None, | |
| ) -> list[TextContent]: | |
| columns = res.get_column_names() | |
| def rows(): | |
| while res.has_next(): | |
| row = res.next().get_fields() | |
| yield ",".join(map(str, row)) | |
| try: | |
| return csv_result_payload_response( | |
| tool_name, | |
| columns, | |
| rows(), | |
| result_store=result_store_from_export_path(export_path), | |
| source={"sql": sql} if sql else None, | |
| owner_session_id=owner_session_id, | |
| max_inline_rows=max_inline_rows, | |
| page_size_rows=page_size_rows, | |
| ) | |
| finally: | |
| session_or_table_session.close() | |
| def register_model_tools(mcp, config: Config, logger: logging.Logger) -> None: | |
| """Register AINode model-management SQL tools with target selection.""" | |
| max_pool_size = 100 | |
| @mcp.tool() | |
| async def model_query( | |
| model_sql: str, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| owner_session_id: str | None = None, | |
| max_inline_rows: int | None = None, | |
| page_size_rows: int | None = None, | |
| ) -> list[TextContent]: | |
| """Execute AINode model-query SQL against the selected IoTDB target.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| with iotdb_target_response_context(selected_config): | |
| sql = _normalize_sql(model_sql) | |
| _assert_prefix( | |
| sql, | |
| ( | |
| "SHOW MODELS", | |
| "SHOW LOADED MODELS", | |
| "SHOW AI_DEVICES", | |
| "SHOW AINODES", | |
| ), | |
| "model_query", | |
| ) | |
| _assert_model_permission(selected_config, action="QUERY") | |
| if selected_config.sql_dialect == "tree": | |
| _, session_pool = tree_session_pool( | |
| config, | |
| target_id=selected_config.target_id, | |
| max_pool_size=max_pool_size, | |
| wait_timeout_in_ms=5000, | |
| tool_name="model_query", | |
| ) | |
| session = None | |
| try: | |
| session = session_pool.get_session() | |
| res = session.execute_query_statement(sql) | |
| return _format_result( | |
| res, | |
| session, | |
| "model_query", | |
| selected_config.export_path, | |
| sql, | |
| owner_session_id=owner_session_id, | |
| max_inline_rows=max_inline_rows, | |
| page_size_rows=page_size_rows, | |
| ) | |
| except Exception as e: | |
| if session: | |
| session.close() | |
| logger.error(f"Failed to execute model_query: {str(e)}") | |
| raise | |
| _, session_pool = table_session_pool( | |
| config, | |
| target_id=selected_config.target_id, | |
| max_pool_size=max_pool_size, | |
| tool_name="model_query", | |
| ) | |
| table_session = None | |
| try: | |
| table_session = session_pool.get_session() | |
| res = table_session.execute_query_statement(sql) | |
| return _format_result( | |
| res, | |
| table_session, | |
| "model_query", | |
| selected_config.export_path, | |
| sql, | |
| owner_session_id=owner_session_id, | |
| max_inline_rows=max_inline_rows, | |
| page_size_rows=page_size_rows, | |
| ) | |
| except Exception as e: | |
| if table_session: | |
| table_session.close() | |
| logger.error(f"Failed to execute model_query: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def prepare_model_inference_request( | |
| model_id: str, | |
| input_sql: str, | |
| output_length: int = 96, | |
| generate_time: bool = False, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Build and validate Tree-dialect AINode CALL INFERENCE SQL.""" | |
| selected_config = select_target_config( | |
| config, | |
| target_id=target_id, | |
| target=target, | |
| required_sql_dialect="tree", | |
| tool_name="prepare_model_inference_request", | |
| ) | |
| with iotdb_target_response_context(selected_config): | |
| inference_sql, validation = _build_inference_sql( | |
| model_id=model_id, | |
| input_sql=input_sql, | |
| output_length=output_length, | |
| generate_time=generate_time, | |
| ) | |
| return payload_response( | |
| "prepare_model_inference_request", | |
| { | |
| "inference_sql": inference_sql, | |
| "validation": validation, | |
| "recommended_next_tool": "model_inference", | |
| }, | |
| ) | |
| @mcp.tool() | |
| async def model_inference( | |
| inference_sql: str, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| owner_session_id: str | None = None, | |
| max_inline_rows: int | None = None, | |
| page_size_rows: int | None = None, | |
| ) -> list[TextContent]: | |
| """Execute Tree-dialect AINode inference SQL.""" | |
| selected_config, session_pool = tree_session_pool( | |
| config, | |
| target_id=target_id, | |
| target=target, | |
| max_pool_size=max_pool_size, | |
| wait_timeout_in_ms=5000, | |
| tool_name="model_inference", | |
| ) | |
| with iotdb_target_response_context(selected_config): | |
| session = None | |
| try: | |
| _assert_model_permission(selected_config, action="QUERY") | |
| sql = _normalize_sql(inference_sql) | |
| _assert_prefix(sql, ("CALL INFERENCE",), "model_inference") | |
| _validate_inference_sql(sql) | |
| session = session_pool.get_session() | |
| res = session.execute_query_statement(sql) | |
| return _format_result( | |
| res, | |
| session, | |
| "model_inference", | |
| selected_config.export_path, | |
| sql, | |
| owner_session_id=owner_session_id, | |
| max_inline_rows=max_inline_rows, | |
| page_size_rows=page_size_rows, | |
| ) | |
| except Exception as e: | |
| if session: | |
| session.close() | |
| logger.error(f"Failed to execute model_inference: {str(e)}") | |
| raise | |
| @mcp.tool() | |
| async def model_command( | |
| model_sql: str, | |
| confirm_destructive: bool = False, | |
| target_id: str | None = None, | |
| target: dict[str, object] | None = None, | |
| ) -> list[TextContent]: | |
| """Execute AINode model-management command SQL.""" | |
| selected_config = select_target_config( | |
| config, target_id=target_id, target=target | |
| ) | |
| with iotdb_target_response_context(selected_config): | |
| sql = _normalize_sql(model_sql) | |
| if _matched_prefix(sql, ("DROP MODEL", "UNLOAD MODEL", "REMOVE AINODE")): | |
| _assert_model_permission( | |
| selected_config, | |
| action="DESTRUCTIVE", | |
| confirm_destructive=confirm_destructive, | |
| ) | |
| elif _matched_prefix(sql, ("CREATE MODEL", "LOAD MODEL")): | |
| _assert_model_permission(selected_config, action="MANAGE") | |
| else: | |
| raise ValueError( | |
| "model_command only supports SQL starting with: " | |
| "CREATE MODEL, DROP MODEL, LOAD MODEL, UNLOAD MODEL, REMOVE AINODE" | |
| ) | |
| if selected_config.sql_dialect == "tree": | |
| _, session_pool = tree_session_pool( | |
| selected_config, | |
| max_pool_size=max_pool_size, | |
| wait_timeout_in_ms=5000, | |
| tool_name="model_command", | |
| ) | |
| session = None | |
| try: | |
| session = session_pool.get_session() | |
| session.execute_non_query_statement(sql) | |
| session.close() | |
| return sql_success_response("model_command", sql) | |
| except Exception as e: | |
| if session: | |
| session.close() | |
| logger.error(f"Failed to execute model_command: {str(e)}") | |
| raise | |
| _, session_pool = table_session_pool( | |
| selected_config, | |
| max_pool_size=max_pool_size, | |
| tool_name="model_command", | |
| ) | |
| table_session = None | |
| try: | |
| table_session = session_pool.get_session() | |
| table_session.execute_non_query_statement(sql) | |
| table_session.close() | |
| return sql_success_response("model_command", sql) | |
| except Exception as e: | |
| if table_session: | |
| table_session.close() | |
| logger.error(f"Failed to execute model_command: {str(e)}") | |
| raise |