| from __future__ import annotations |
| |
| import datetime |
| import logging |
| import re |
| import uuid |
| from typing import Any |
| |
| 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.services.json_response import ( |
| csv_result_payload_response, |
| payload_response, |
| text_payload_response, |
| ) |
| from iotdb_mcp_server.services.query import ( |
| _ensure_export_directory, |
| _prepare_table_res, |
| _prepare_tree_res, |
| sanitize_filename, |
| ) |
| from iotdb_mcp_server.services.target_selection import ( |
| iotdb_target_response_context, |
| select_target_config, |
| table_session_pool, |
| tree_session_pool, |
| ) |
| from iotdb_mcp_server.services.tree_sql_guardrails import assert_tree_query_shape |
| |
| _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
| _TREE_PATH_RE = re.compile(r"^root(?:\.[A-Za-z_][A-Za-z0-9_]*|\.\*|\.\*\*)*$") |
| _TREE_EXPR_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$") |
| _DANGEROUS_SQL_RE = re.compile( |
| r";|--|/\*|\*/|\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|LOAD|SET|GRANT|REVOKE|TRUNCATE)\b", |
| re.IGNORECASE, |
| ) |
| |
| |
| def _validate_identifier(value: str, label: str) -> str: |
| cleaned = str(value or "").strip() |
| if not _IDENTIFIER_RE.fullmatch(cleaned): |
| raise ValueError(f"Invalid {label}. Use letters, numbers, and underscore only.") |
| return cleaned |
| |
| |
| def _validate_function_name(function_name: str) -> str: |
| return _validate_identifier(function_name, "function_name") |
| |
| |
| def _validate_tree_path(path: str) -> str: |
| cleaned = str(path or "").strip() |
| if not _TREE_PATH_RE.fullmatch(cleaned): |
| raise ValueError("Invalid tree from_path. Use path like root.sg.d1 or root.sg.*.") |
| return cleaned |
| |
| |
| def _validate_expression(expression: str, *, dialect: str) -> str: |
| cleaned = str(expression or "").strip() |
| if not cleaned: |
| raise ValueError("UDF expression cannot be empty.") |
| if _DANGEROUS_SQL_RE.search(cleaned): |
| raise ValueError("UDF expression contains unsupported SQL syntax.") |
| if dialect == "table": |
| return _validate_identifier(cleaned, "table expression") |
| if not _TREE_EXPR_RE.fullmatch(cleaned): |
| raise ValueError("Invalid tree expression. Use measurement names or relative paths.") |
| return cleaned |
| |
| |
| def _validate_where_clause(where_clause: str | None) -> str | None: |
| if where_clause is None: |
| return None |
| cleaned = str(where_clause).strip() |
| if not cleaned: |
| return None |
| if _DANGEROUS_SQL_RE.search(cleaned): |
| raise ValueError("where_clause contains unsupported or non-readonly SQL syntax.") |
| if cleaned.upper().startswith("WHERE "): |
| cleaned = cleaned[6:].strip() |
| if not cleaned: |
| return None |
| return cleaned |
| |
| |
| def _render_attribute_value(value: Any) -> str: |
| text = str(value) |
| escaped = text.replace("\\", "\\\\").replace('"', '\\"') |
| return f'"{escaped}"' |
| |
| |
| def _render_attributes(attributes: dict[str, Any] | None) -> list[str]: |
| if not attributes: |
| return [] |
| rendered = [] |
| for key, value in attributes.items(): |
| safe_key = _validate_identifier(str(key), "attribute key") |
| rendered.append(f'"{safe_key}"={_render_attribute_value(value)}') |
| return rendered |
| |
| |
| def _normalize_expressions(expressions: list[str] | str | None, *, dialect: str) -> list[str]: |
| if expressions is None: |
| raise ValueError("expressions is required.") |
| raw_values = [expressions] if isinstance(expressions, str) else list(expressions) |
| if not raw_values: |
| raise ValueError("expressions cannot be empty.") |
| return [_validate_expression(str(value), dialect=dialect) for value in raw_values] |
| |
| |
| def _build_udf_query_plan( |
| *, |
| dialect: str, |
| function_name: str, |
| expressions: list[str] | str, |
| table_name: str | None = None, |
| from_path: str | None = None, |
| attributes: dict[str, Any] | None = None, |
| where_clause: str | None = None, |
| limit: int | None = None, |
| alias: str | None = None, |
| align_by_device: bool = False, |
| ) -> dict[str, Any]: |
| safe_dialect = str(dialect or "").strip().lower() |
| if safe_dialect not in {"tree", "table"}: |
| raise ValueError("dialect must be tree or table.") |
| safe_function = _validate_function_name(function_name) |
| safe_expressions = _normalize_expressions(expressions, dialect=safe_dialect) |
| function_args = safe_expressions + _render_attributes(attributes) |
| projection = f"{safe_function}({', '.join(function_args)})" |
| if alias: |
| projection += f" AS {_validate_identifier(alias, 'alias')}" |
| |
| where = _validate_where_clause(where_clause) |
| clauses: list[str] |
| if safe_dialect == "table": |
| source = _validate_identifier(str(table_name or ""), "table_name") |
| clauses = [f"SELECT {projection}", f"FROM {source}"] |
| else: |
| source = _validate_tree_path(str(from_path or "")) |
| clauses = [f"SELECT {projection}", f"FROM {source}"] |
| |
| if where: |
| clauses.append(f"WHERE {where}") |
| if limit is not None: |
| safe_limit = int(limit) |
| if safe_limit <= 0: |
| raise ValueError("limit must be positive.") |
| clauses.append(f"LIMIT {safe_limit}") |
| if safe_dialect == "tree" and align_by_device: |
| clauses.append("ALIGN BY DEVICE") |
| |
| sql = " ".join(clauses) |
| return { |
| "version": "v0", |
| "kind": "iotdb_udf_query_plan_ir", |
| "sql": sql, |
| "dialect": safe_dialect, |
| "function_name": safe_function, |
| "expressions": safe_expressions, |
| "source": {"table_name": source} if safe_dialect == "table" else {"from_path": source}, |
| "attributes": attributes or {}, |
| "where_clause": where, |
| "limit": limit, |
| "alias": alias, |
| "align_by_device": bool(align_by_device), |
| "readonly": True, |
| } |
| |
| |
| def _export_dataset( |
| *, |
| res: Any, |
| session: Any, |
| selected_config: Config, |
| tool_name: str, |
| query_sql: str, |
| filename: str | None, |
| fmt: str, |
| logger: logging.Logger, |
| ) -> list[TextContent]: |
| try: |
| df = res.todf() |
| finally: |
| session.close() |
| |
| timestamp = int(datetime.datetime.now().timestamp()) |
| if filename is None: |
| filename = f"udf_{uuid.uuid4().hex[:4]}_{timestamp}" |
| |
| fmt_lower = fmt.lower() |
| if fmt_lower == "csv": |
| if filename.lower().endswith(".csv"): |
| filename = filename[:-4] |
| filepath = sanitize_filename(f"{filename}.csv", selected_config.export_path) |
| df.to_csv(filepath, index=False) |
| elif fmt_lower == "excel": |
| if filename.lower().endswith(".xlsx"): |
| filename = filename[:-5] |
| filepath = sanitize_filename(f"{filename}.xlsx", selected_config.export_path) |
| df.to_excel(filepath, index=False) |
| else: |
| raise ValueError("format must be either 'csv' or 'excel'.") |
| |
| preview_rows = min(10, len(df)) |
| preview_data = [",".join(df.columns)] |
| for index in range(preview_rows): |
| preview_data.append(",".join(map(str, df.iloc[index]))) |
| logger.info("Exported UDF query result to %s", filepath) |
| return text_payload_response( |
| tool_name, |
| f"Query results exported to {filepath}\n\nSQL:\n{query_sql}\n\nPreview (first {preview_rows} rows):\n" |
| + "\n".join(preview_data), |
| ) |
| |
| |
| def _prepare_udf_res( |
| res: Any, |
| session: Any, |
| tool_name: str, |
| export_path: str, |
| plan: dict[str, Any], |
| 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(): |
| record = res.next() |
| fields = record.get_fields() |
| if plan["dialect"] == "tree" and columns and columns[0] == "Time": |
| yield str(record.get_timestamp()) + "," + ",".join( |
| map(str, fields) |
| ) |
| else: |
| yield ",".join(map(str, fields)) |
| |
| try: |
| return csv_result_payload_response( |
| tool_name, |
| columns, |
| rows(), |
| result_store=result_store_from_export_path(export_path), |
| source={"query_sql": plan["sql"], "udf_query_plan": plan}, |
| diagnostics=[ |
| { |
| "code": "iotdb_udf_query_plan", |
| "query_sql": plan["sql"], |
| "function_name": plan["function_name"], |
| "dialect": plan["dialect"], |
| "readonly": True, |
| } |
| ], |
| owner_session_id=owner_session_id, |
| max_inline_rows=max_inline_rows, |
| page_size_rows=page_size_rows, |
| ) |
| finally: |
| session.close() |
| |
| |
| def register_udf_tools(mcp, config: Config, logger: logging.Logger) -> None: |
| """Register read-only IoTDB UDF discovery and execution tools.""" |
| _ensure_export_directory(config.export_path, logger) |
| max_pool_size = 100 |
| |
| @mcp.tool() |
| async def list_udf_functions( |
| 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]: |
| """List IoTDB functions/UDFs visible to the selected target via SHOW FUNCTIONS.""" |
| selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="list_udf_functions") |
| with iotdb_target_response_context(selected_config): |
| 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="list_udf_functions", |
| ) |
| session = None |
| try: |
| session = session_pool.get_session() |
| res = session.execute_query_statement("SHOW FUNCTIONS") |
| return _prepare_tree_res( |
| res, |
| session, |
| "list_udf_functions", |
| selected_config.export_path, |
| "SHOW FUNCTIONS", |
| owner_session_id=owner_session_id, |
| max_inline_rows=max_inline_rows, |
| page_size_rows=page_size_rows, |
| ) |
| except Exception: |
| if session: |
| session.close() |
| raise |
| |
| _, session_pool = table_session_pool( |
| config, |
| target_id=selected_config.target_id, |
| max_pool_size=max_pool_size, |
| tool_name="list_udf_functions", |
| ) |
| table_session = None |
| try: |
| table_session = session_pool.get_session() |
| res = table_session.execute_query_statement("SHOW FUNCTIONS") |
| return _prepare_table_res( |
| res, |
| table_session, |
| "list_udf_functions", |
| selected_config.export_path, |
| "SHOW FUNCTIONS", |
| owner_session_id=owner_session_id, |
| max_inline_rows=max_inline_rows, |
| page_size_rows=page_size_rows, |
| ) |
| except Exception: |
| if table_session: |
| table_session.close() |
| raise |
| |
| @mcp.tool() |
| async def prepare_udf_query( |
| function_name: str, |
| expressions: list[str] | str, |
| table_name: str | None = None, |
| from_path: str | None = None, |
| attributes: dict[str, object] | None = None, |
| where_clause: str | None = None, |
| limit: int | None = None, |
| alias: str | None = None, |
| align_by_device: bool = False, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Build a conservative read-only SQL plan for invoking an IoTDB UDF.""" |
| selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="prepare_udf_query") |
| with iotdb_target_response_context(selected_config): |
| payload = _build_udf_query_plan( |
| dialect=selected_config.sql_dialect, |
| function_name=function_name, |
| expressions=expressions, |
| table_name=table_name, |
| from_path=from_path, |
| attributes=attributes, |
| where_clause=where_clause, |
| limit=limit, |
| alias=alias, |
| align_by_device=align_by_device, |
| ) |
| return payload_response("prepare_udf_query", payload, message="UDF query plan prepared.") |
| |
| @mcp.tool() |
| async def execute_udf_query( |
| function_name: str, |
| expressions: list[str] | str, |
| table_name: str | None = None, |
| from_path: str | None = None, |
| attributes: dict[str, object] | None = None, |
| where_clause: str | None = None, |
| limit: int | None = None, |
| alias: str | None = None, |
| align_by_device: bool = False, |
| 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 one read-only IoTDB UDF SELECT built from validated inputs.""" |
| selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="execute_udf_query") |
| with iotdb_target_response_context(selected_config): |
| plan = _build_udf_query_plan( |
| dialect=selected_config.sql_dialect, |
| function_name=function_name, |
| expressions=expressions, |
| table_name=table_name, |
| from_path=from_path, |
| attributes=attributes, |
| where_clause=where_clause, |
| limit=limit, |
| alias=alias, |
| align_by_device=align_by_device, |
| ) |
| sql = plan["sql"] |
| 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="execute_udf_query", |
| ) |
| session = None |
| try: |
| assert_tree_query_shape(sql) |
| session = session_pool.get_session() |
| res = session.execute_query_statement(sql) |
| return _prepare_udf_res( |
| res, |
| session, |
| "execute_udf_query", |
| selected_config.export_path, |
| plan, |
| owner_session_id=owner_session_id, |
| max_inline_rows=max_inline_rows, |
| page_size_rows=page_size_rows, |
| ) |
| except Exception: |
| if session: |
| session.close() |
| raise |
| |
| _, session_pool = table_session_pool( |
| config, |
| target_id=selected_config.target_id, |
| max_pool_size=max_pool_size, |
| tool_name="execute_udf_query", |
| ) |
| table_session = None |
| try: |
| table_session = session_pool.get_session() |
| res = table_session.execute_query_statement(sql) |
| return _prepare_udf_res( |
| res, |
| table_session, |
| "execute_udf_query", |
| selected_config.export_path, |
| plan, |
| owner_session_id=owner_session_id, |
| max_inline_rows=max_inline_rows, |
| page_size_rows=page_size_rows, |
| ) |
| except Exception: |
| if table_session: |
| table_session.close() |
| raise |
| |
| @mcp.tool() |
| async def export_udf_query( |
| function_name: str, |
| expressions: list[str] | str, |
| table_name: str | None = None, |
| from_path: str | None = None, |
| attributes: dict[str, object] | None = None, |
| where_clause: str | None = None, |
| limit: int | None = None, |
| alias: str | None = None, |
| align_by_device: bool = False, |
| format: str = "csv", |
| filename: str | None = None, |
| target_id: str | None = None, |
| target: dict[str, object] | None = None, |
| ) -> list[TextContent]: |
| """Execute one read-only IoTDB UDF SELECT and export the result set.""" |
| selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="export_udf_query") |
| with iotdb_target_response_context(selected_config): |
| _ensure_export_directory(selected_config.export_path, logger) |
| plan = _build_udf_query_plan( |
| dialect=selected_config.sql_dialect, |
| function_name=function_name, |
| expressions=expressions, |
| table_name=table_name, |
| from_path=from_path, |
| attributes=attributes, |
| where_clause=where_clause, |
| limit=limit, |
| alias=alias, |
| align_by_device=align_by_device, |
| ) |
| sql = plan["sql"] |
| 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="export_udf_query", |
| ) |
| session = None |
| try: |
| assert_tree_query_shape(sql) |
| session = session_pool.get_session() |
| res = session.execute_query_statement(sql) |
| return _export_dataset( |
| res=res, |
| session=session, |
| selected_config=selected_config, |
| tool_name="export_udf_query", |
| query_sql=sql, |
| filename=filename, |
| fmt=format, |
| logger=logger, |
| ) |
| except Exception: |
| if session: |
| session.close() |
| raise |
| |
| _, session_pool = table_session_pool( |
| config, |
| target_id=selected_config.target_id, |
| max_pool_size=max_pool_size, |
| tool_name="export_udf_query", |
| ) |
| table_session = None |
| try: |
| table_session = session_pool.get_session() |
| res = table_session.execute_query_statement(sql) |
| return _export_dataset( |
| res=res, |
| session=table_session, |
| selected_config=selected_config, |
| tool_name="export_udf_query", |
| query_sql=sql, |
| filename=filename, |
| fmt=format, |
| logger=logger, |
| ) |
| except Exception: |
| if table_session: |
| table_session.close() |
| raise |