blob: b4c138df23ee6e14fa0d8c6756969b54e586d59a [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 json
from contextvars import ContextVar
from datetime import datetime, timezone
from typing import Any, Callable, Iterable
from mcp.types import TextContent
from iotdb_mcp_server.result_store import FileResultStore
_RESPONSE_VERSION = "1.0"
_target_context: ContextVar[dict[str, Any] | None] = ContextVar(
"iotdb_target_context", default=None
)
_TRUE_VALUES = {"1", "true", "yes", "on", "available", "running", "enabled"}
_FALSE_VALUES = {"0", "false", "no", "off", "unavailable", "stopped", "disabled"}
def set_iotdb_target_context(target: dict[str, Any] | None):
return _target_context.set(target)
def reset_iotdb_target_context(token) -> None:
_target_context.reset(token)
def _coerce_availability(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, bool):
return "yes" if value else "no"
normalized = str(value).strip().lower()
if normalized in _TRUE_VALUES:
return "yes"
if normalized in _FALSE_VALUES:
return "no"
if normalized in {"unknown", "probe_failed", "not_probed"}:
return "unknown"
return None
def _ainode_availability(target: dict[str, Any]) -> tuple[str, Any]:
availability = _coerce_availability(target.get("ainode_available")) or "unknown"
source = target.get("ainode_availability_source")
if source is None:
source = {
"method": "SHOW AINODES",
"ok": False,
"error": "not_probed",
}
return availability, source
def _cli_endpoint(target: dict[str, Any]) -> str:
node_urls = target.get("node_urls")
if isinstance(node_urls, list) and node_urls:
first = str(node_urls[0]).strip()
if first:
return first
host = str(target.get("host") or "127.0.0.1").strip()
port = str(target.get("port") or "6667").strip()
return f"{host}:{port}"
def _row_count(payload: Any) -> int:
if not isinstance(payload, dict):
return 0
rows = payload.get("rows")
if isinstance(rows, list):
return len(rows)
for key in ("row_count", "rows_count", "count"):
value = payload.get(key)
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return 0
def _iotdb_context(tool: str, payload: Any, target: dict[str, Any]) -> dict[str, Any]:
ainode_available, ainode_source = _ainode_availability(target)
return {
"target_id": target.get("target_id"),
"cli_endpoint": _cli_endpoint(target),
"ainode_available": ainode_available,
"ainode_availability_source": ainode_source,
"dialect": target.get("sql_dialect"),
"database": target.get("database"),
"tool": tool,
"rows": _row_count(payload),
}
def _render_iotdb_context(context: dict[str, Any]) -> str:
lines = [
"TimeSeek IoTDB context:",
f"- target_id: {context.get('target_id') or ''}",
f"- cli_endpoint: {context.get('cli_endpoint') or ''}",
f"- ainode_available: {context.get('ainode_available') or 'unknown'}",
f"- dialect: {context.get('dialect') or ''}",
f"- database: {context.get('database') or ''}",
f"- tool: {context.get('tool') or ''}",
f"- rows: {context.get('rows', 0)}",
]
return "\n".join(lines)
class JsonParser:
"""Simple parser/checker for MCP tool JSON envelopes."""
_required_keys = ("version", "tool", "ok", "timestamp", "payload")
@staticmethod
def check_format(obj: Any) -> bool:
if not isinstance(obj, dict):
return False
for key in JsonParser._required_keys:
if key not in obj:
return False
if not isinstance(obj["version"], str):
return False
if not isinstance(obj["tool"], str) or not obj["tool"].strip():
return False
if not isinstance(obj["ok"], bool):
return False
if not isinstance(obj["timestamp"], str) or not obj["timestamp"].strip():
return False
return True
@staticmethod
def parse(text: str) -> dict[str, Any]:
try:
obj = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON text: {e.msg}") from e
if not JsonParser.check_format(obj):
raise ValueError(
"Invalid wrapped response format. Expected keys: "
"version, tool, ok, timestamp, payload."
)
return obj
def _envelope(
tool: str, payload: Any, ok: bool = True, message: str | None = None
) -> dict[str, Any]:
target = _target_context.get()
context = _iotdb_context(tool, payload, target) if target else None
if target:
if isinstance(payload, dict):
payload = dict(payload)
payload.setdefault("iotdb_target", target)
if context:
payload.setdefault("iotdb_context", context)
else:
payload = {
"value": payload,
"iotdb_target": target,
}
if context:
payload["iotdb_context"] = context
obj: dict[str, Any] = {
"version": _RESPONSE_VERSION,
"tool": tool,
"ok": ok,
"timestamp": datetime.now(timezone.utc).isoformat(),
"payload": payload,
}
if context:
obj["context"] = {"iotdb": context}
if message is not None:
obj["message"] = message
return obj
def _to_text_content(obj: dict[str, Any]) -> list[TextContent]:
if not JsonParser.check_format(obj):
raise ValueError("JsonParser format check failed before output.")
text = json.dumps(obj, ensure_ascii=False)
JsonParser.parse(text)
context = obj.get("context")
iotdb_context = context.get("iotdb") if isinstance(context, dict) else None
if isinstance(iotdb_context, dict):
return [
TextContent(type="text", text=_render_iotdb_context(iotdb_context)),
TextContent(type="text", text=text),
]
return [TextContent(type="text", text=text)]
def payload_response(
tool: str, payload: Any, message: str | None = None
) -> list[TextContent]:
return _to_text_content(_envelope(tool=tool, payload=payload, message=message))
def error_response(tool: str, error: str) -> list[TextContent]:
return _to_text_content(
_envelope(
tool=tool,
payload={"error": error},
ok=False,
message="Tool execution failed.",
)
)
def text_payload_response(
tool: str, text: str, message: str | None = None
) -> list[TextContent]:
return payload_response(
tool=tool,
payload={"format": "text", "text": text},
message=message,
)
def _csv_text(columns: list[str], rows: list[str]) -> str:
return "\n".join([",".join(columns)] + rows)
def csv_payload_response(
tool: str,
columns: list[str],
rows: list[str],
message: str | None = None,
diagnostics: list[dict[str, Any]] | None = None,
) -> list[TextContent]:
csv_text = _csv_text(columns, rows)
payload: dict[str, Any] = {
"format": "csv",
"columns": columns,
"rows": rows,
"text": csv_text,
"row_count": len(rows),
"inline_row_count": len(rows),
"inline_truncated": False,
}
if diagnostics:
payload["diagnostics"] = diagnostics
return payload_response(
tool=tool,
payload=payload,
message=message,
)
def csv_result_payload_response(
tool: str,
columns: list[str],
rows: Iterable[str],
*,
result_store: FileResultStore,
source: dict[str, Any] | None = None,
message: str | None = None,
diagnostics: list[dict[str, Any]] | None = None,
diagnostics_factory: Callable[[int], list[dict[str, Any]] | None] | None = None,
owner_session_id: str | None = None,
max_inline_rows: int | None = None,
page_size_rows: int | None = None,
) -> list[TextContent]:
stored = result_store.write_csv_result(
tool=tool,
columns=columns,
rows=rows,
source=source,
owner_session_id=owner_session_id,
preview_rows=max_inline_rows,
page_size_rows=page_size_rows,
)
preview_rows = stored.preview_rows
inline_truncated = stored.row_count > len(preview_rows)
payload: dict[str, Any] = {
"format": "csv",
"columns": columns,
"rows": preview_rows,
"text": _csv_text(columns, preview_rows),
"row_count": stored.row_count,
"inline_row_count": len(preview_rows),
"inline_truncated": inline_truncated,
"result_id": stored.result_id,
"owner_session_id": stored.owner_session_id,
"next_cursor": str(len(preview_rows)) if inline_truncated else None,
"result_store": {
"type": "file",
"result_id": stored.result_id,
"owner_session_id": stored.owner_session_id,
"page_tool": "read_result_page",
"batch_page_tool": "read_result_pages",
"page_size_rows": stored.page_size_rows,
"manifest_path": stored.manifest_path,
"shard_count": stored.shard_count,
"byte_count": stored.byte_count,
},
}
combined_diagnostics = list(diagnostics or [])
if diagnostics_factory is not None:
combined_diagnostics.extend(diagnostics_factory(stored.row_count) or [])
if combined_diagnostics:
payload["diagnostics"] = combined_diagnostics
return payload_response(tool=tool, payload=payload, message=message)
def sql_success_response(tool: str, sql: str) -> list[TextContent]:
return payload_response(
tool=tool,
payload={"sql": sql, "result": "success"},
message="SQL executed successfully.",
)