blob: e720c351df27c508459acfd9cb1c93b91337e4b4 [file]
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