blob: b7a83985614288f3284814860a49210c8053bd3e [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 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
from iotdb_mcp_server.services.target_selection import (
iotdb_target_response_context,
select_target_config,
table_session_pool,
tree_session_pool,
)
_TREE_PATH_PATTERN = re.compile(r"^root(?:\.[A-Za-z_][A-Za-z0-9_]*|\.\*|\.\*\*)*$")
_TABLE_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
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_metadata_permission(config: Config) -> None:
if strict_permission_enforcement():
if not _env_bool("IOTDB_ENABLE_METADATA_QUERY", True):
raise PermissionError(
"Metadata tools are disabled by strict MCP policy. "
"Set IOTDB_ENABLE_METADATA_QUERY=true to enable."
)
allowed_users = _csv_set(
dynamic_getenv("IOTDB_METADATA_ALLOWED_USERS", "*") 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_METADATA_ALLOWED_USERS."
)
def _normalize_sql(sql: str) -> str:
cleaned = sql.strip()
if not cleaned:
raise ValueError("Metadata SQL cannot be empty.")
if cleaned.endswith(";"):
cleaned = cleaned[:-1].strip()
if ";" in cleaned:
raise ValueError("Only a single SQL statement is allowed.")
return cleaned
def _validate_tree_path(path: str) -> str:
cleaned = path.strip()
if not cleaned:
raise ValueError("Tree path cannot be empty.")
if not _TREE_PATH_PATTERN.fullmatch(cleaned):
raise ValueError(
"Invalid tree path. Use path like root.sg, root.sg.dev, root.**, root.sg.*"
)
return cleaned
def _validate_table_identifier(identifier: str) -> str:
cleaned = identifier.strip()
if not _TABLE_IDENTIFIER_PATTERN.fullmatch(cleaned):
raise ValueError(
"Invalid table identifier. Use letters, numbers, and underscore only."
)
return cleaned
def _format_result(
res: SessionDataSet,
session_or_table_session: Session | TableSession,
tool_name: str,
export_path: str,
query_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={"query_sql": query_sql} if query_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 _ensure_prefix(
sql: str, allowed_prefixes: tuple[str, ...], action_name: str
) -> None:
upper = sql.upper()
if not upper.startswith(allowed_prefixes):
raise ValueError(
f"{action_name} only supports SQL starting with: {', '.join(allowed_prefixes)}"
)
def register_metadata_tools(mcp, config: Config, logger: logging.Logger) -> None:
"""Register metadata tools with per-call IoTDB target selection."""
max_pool_size = 100
tree_prefixes = (
"SHOW DATABASES",
"SHOW TIMESERIES",
"SHOW DEVICES",
"SHOW CHILD PATHS",
"SHOW CHILD NODES",
"SHOW FUNCTIONS",
"COUNT TIMESERIES",
"COUNT NODES",
"COUNT DEVICES",
)
table_prefixes = ("SHOW", "DESC", "DESCRIBE")
def _require_target_dialect(
required_sql_dialect: str,
tool_name: str,
target_id: str | None,
target: dict[str, object] | None,
) -> None:
select_target_config(
config,
target_id=target_id,
target=target,
required_sql_dialect=required_sql_dialect,
tool_name=tool_name,
)
async def _metadata_query_impl(
query_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 metadata 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):
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="metadata_query",
)
session = None
try:
_assert_metadata_permission(selected_config)
sql = _normalize_sql(query_sql)
_ensure_prefix(sql, tree_prefixes, "metadata_query")
session = session_pool.get_session()
res = session.execute_query_statement(sql)
return _format_result(
res,
session,
"metadata_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 metadata_query: {str(e)}")
raise
_, session_pool = table_session_pool(
config,
target_id=selected_config.target_id,
max_pool_size=max_pool_size,
tool_name="metadata_query",
)
table_session = None
try:
_assert_metadata_permission(selected_config)
sql = _normalize_sql(query_sql)
_ensure_prefix(sql, table_prefixes, "metadata_query")
table_session = session_pool.get_session()
res = table_session.execute_query_statement(sql)
return _format_result(
res,
table_session,
"metadata_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 metadata_query: {str(e)}")
raise
@mcp.tool()
async def metadata_query(
query_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 metadata SQL against the selected IoTDB target."""
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
max_inline_rows=max_inline_rows,
page_size_rows=page_size_rows,
)
@mcp.tool()
async def list_timeseries(
path: str = "root.**",
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""List timeseries under a tree path pattern."""
_require_target_dialect("tree", "list_timeseries", target_id, target)
query_sql = f"SHOW TIMESERIES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def list_devices(
path: str = "root.**",
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""List devices under a tree path pattern."""
_require_target_dialect("tree", "list_devices", target_id, target)
query_sql = f"SHOW DEVICES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def list_child_paths(
path: str,
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""List child paths under a tree path."""
_require_target_dialect("tree", "list_child_paths", target_id, target)
query_sql = f"SHOW CHILD PATHS {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def list_child_nodes(
path: str,
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""List child nodes under a tree path."""
_require_target_dialect("tree", "list_child_nodes", target_id, target)
query_sql = f"SHOW CHILD NODES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def count_timeseries(
path: str = "root.**",
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""Count timeseries under a tree path pattern."""
_require_target_dialect("tree", "count_timeseries", target_id, target)
query_sql = f"COUNT TIMESERIES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def count_devices(
path: str = "root.**",
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""Count devices under a tree path pattern."""
_require_target_dialect("tree", "count_devices", target_id, target)
query_sql = f"COUNT DEVICES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def count_nodes(
path: str = "root",
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""Count nodes under a tree path."""
_require_target_dialect("tree", "count_nodes", target_id, target)
query_sql = f"COUNT NODES {_validate_tree_path(path)}"
return await _metadata_query_impl(
query_sql,
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def list_tables(
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""List all tables in current table-model database."""
_require_target_dialect("table", "list_tables", target_id, target)
return await _metadata_query_impl(
"SHOW TABLES",
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)
@mcp.tool()
async def describe_table(
table_name: str,
details: bool = True,
target_id: str | None = None,
target: dict[str, object] | None = None,
owner_session_id: str | None = None,
) -> list[TextContent]:
"""Describe schema for a table in current table-model database."""
_require_target_dialect("table", "describe_table", target_id, target)
safe_table = _validate_table_identifier(table_name)
details_suffix = " details" if details else ""
return await _metadata_query_impl(
f"DESC {safe_table}{details_suffix}",
target_id=target_id,
target=target,
owner_session_id=owner_session_id,
)