blob: 3c67e1d66d7250a8122d91618bc9e8a4f5d9479b [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
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.services.json_response import error_response, payload_response
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
def _collect_result(
res: SessionDataSet, session_or_table_session: Session | TableSession
) -> tuple[list[str], list[str]]:
columns = res.get_column_names()
rows: list[str] = []
while res.has_next():
row = res.next().get_fields()
rows.append(",".join(map(str, row)))
session_or_table_session.close()
return columns, rows
def _normalize_and_validate_sql(
sql_dialect: str, sql: str, analyze: bool
) -> tuple[str, str]:
raw_sql = sql.strip()
if not raw_sql:
raise ValueError("SQL cannot be empty")
explain_sql = raw_sql
upper = raw_sql.upper()
if not upper.startswith("EXPLAIN"):
explain_prefix = "EXPLAIN ANALYZE " if analyze else "EXPLAIN "
explain_sql = explain_prefix + raw_sql
target_sql = raw_sql.strip()
else:
# Accept user-provided EXPLAIN; do not override existing options.
target_sql = raw_sql[len("EXPLAIN") :].strip()
if target_sql.upper().startswith("ANALYZE"):
target_sql = target_sql[len("ANALYZE") :].strip()
target_upper = target_sql.upper()
allowed_prefixes = (
("SELECT", "SHOW", "COUNT", "WITH")
if sql_dialect == "tree"
else ("SELECT", "SHOW", "DESC", "DESCRIBE", "WITH")
)
if not target_upper.startswith(allowed_prefixes):
raise ValueError(
f"explain_query only supports query-like SQL for {sql_dialect} dialect. "
f"Allowed prefixes: {', '.join(allowed_prefixes)}"
)
if sql_dialect == "tree" and target_upper.startswith("SELECT"):
assert_tree_query_shape(target_sql)
return explain_sql, target_sql
def register_explain_tools(mcp, config: Config, logger: logging.Logger) -> None:
"""Register EXPLAIN tool with per-call IoTDB target selection."""
max_pool_size = 100
@mcp.tool()
async def explain_query(
query_sql: str,
analyze: bool = False,
target_id: str | None = None,
target: dict[str, object] | None = None,
) -> list[TextContent]:
"""Execute EXPLAIN for one statement against the selected IoTDB target."""
selected_config = select_target_config(config, target_id=target_id, target=target)
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="explain_query",
)
session = None
try:
explain_sql, _ = _normalize_and_validate_sql(
selected_config.sql_dialect, query_sql, analyze
)
session = session_pool.get_session()
res = session.execute_query_statement(explain_sql)
columns, rows = _collect_result(res, session)
return payload_response(
"explain_query",
{
"explain_sql": explain_sql,
"plan": {
"format": "csv",
"columns": columns,
"rows": rows,
"text": "\n".join([",".join(columns)] + rows),
},
},
message="Explain executed.",
)
except Exception as e:
if session:
session.close()
logger.error(f"Failed to execute explain query: {str(e)}")
return error_response("explain_query", str(e))
_, session_pool = table_session_pool(
config,
target_id=selected_config.target_id,
max_pool_size=max_pool_size,
tool_name="explain_query",
)
table_session = None
try:
explain_sql, _ = _normalize_and_validate_sql(
selected_config.sql_dialect, query_sql, analyze
)
table_session = session_pool.get_session()
res = table_session.execute_query_statement(explain_sql)
columns, rows = _collect_result(res, table_session)
return payload_response(
"explain_query",
{
"explain_sql": explain_sql,
"plan": {
"format": "csv",
"columns": columns,
"rows": rows,
"text": "\n".join([",".join(columns)] + rows),
},
},
message="Explain executed.",
)
except Exception as e:
if table_session:
table_session.close()
logger.error(f"Failed to execute explain query: {str(e)}")
return error_response("explain_query", str(e))