blob: 577c17230eea80c2f8ea8bf4b10cf56b8d930200 [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 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 "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_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