server.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. from __future__ import annotations
  2. import os
  3. from typing import Annotated
  4. from typing import Any
  5. from fastmcp import FastMCP
  6. from pydantic import Field
  7. from starlette.requests import Request
  8. from starlette.responses import JSONResponse, Response
  9. from .auth import load_projects_config
  10. from .config_api import (
  11. list_device_types as api_list_device_types,
  12. list_locations as api_list_locations,
  13. list_meter_types as api_list_meter_types,
  14. list_system_tree as api_list_system_tree,
  15. list_systems as api_list_systems,
  16. search_devices as api_search_devices,
  17. search_meters as api_search_meters,
  18. search_points as api_search_points,
  19. )
  20. from .topology_cache import (
  21. find_topology_context,
  22. get_topology_node,
  23. list_topologies,
  24. list_topology_groups,
  25. refresh_topology_cache,
  26. )
  27. mcp = FastMCP("instrument-config")
  28. @mcp.tool(
  29. name="project.list",
  30. title="Project List",
  31. description="List enabled projects configured in sys_config for instrument-config tools. 在执行其他工具前,询问用户要查询哪一个项目,根据用户的选择查询对应项目。",
  32. tags={"project", "list"},
  33. )
  34. def project_list() -> dict[str, Any]:
  35. projects = load_projects_config()
  36. result: list[dict[str, Any]] = []
  37. for item in projects:
  38. if not item["enabled"]:
  39. continue
  40. result.append(
  41. {
  42. "project_key": item["project_key"],
  43. "project_name": item["project_name"],
  44. }
  45. )
  46. result.sort(key=lambda item: item["project_key"])
  47. return {
  48. "projects": result,
  49. "total": len(result),
  50. }
  51. def _append_next_page_hint(payload: Any, page_num: int) -> Any:
  52. if not isinstance(payload, dict):
  53. return payload
  54. data = payload.get("data")
  55. if not isinstance(data, dict):
  56. return payload
  57. total_page = data.get("total_page")
  58. if isinstance(total_page, int) and total_page > page_num:
  59. payload.setdefault(
  60. "mcp_note",
  61. f"Current result is page {page_num}. If the target was not found, continue with page_num={page_num + 1}.",
  62. )
  63. return payload
  64. def _parse_bool_query(raw_value: str | None) -> bool:
  65. text = str(raw_value or "").strip().lower()
  66. if text in {"1", "true", "yes", "y", "on"}:
  67. return True
  68. if text in {"0", "false", "no", "n", "off", ""}:
  69. return False
  70. raise ValueError(f"invalid boolean query value: {raw_value}")
  71. def _parse_int_list_query(values: list[str]) -> list[int]:
  72. result: list[int] = []
  73. for item in values:
  74. text = str(item or "").strip()
  75. if not text:
  76. continue
  77. result.append(int(text))
  78. return result
  79. @mcp.custom_route("/topology/cache/refresh", methods=["GET"], include_in_schema=False)
  80. async def refresh_topology_cache_route(request: Request) -> Response:
  81. try:
  82. project_key = str(request.query_params.get("project_key") or "").strip()
  83. if not project_key:
  84. raise ValueError("project_key is required")
  85. topology_ids = _parse_int_list_query(
  86. request.query_params.getlist("topology_id")
  87. )
  88. force = _parse_bool_query(request.query_params.get("force"))
  89. del force # Manual refresh always rebuilds the requested cache scope.
  90. payload = refresh_topology_cache(project_key, topology_ids=topology_ids or None)
  91. return JSONResponse(payload)
  92. except Exception as exc:
  93. return JSONResponse({"error": str(exc)}, status_code=400)
  94. @mcp.tool()
  95. def list_locations(
  96. project_key: str, keyword: str = "", page_size: int = 100, page_num: int = 1
  97. ) -> Any:
  98. """List location data from the config API."""
  99. payload = api_list_locations(
  100. project_key, keyword=keyword, page_size=page_size, page_num=page_num
  101. )
  102. return _append_next_page_hint(payload, page_num)
  103. @mcp.tool()
  104. def list_system_tree(project_key: str) -> Any:
  105. """Get the full system tree from the config API."""
  106. return api_list_system_tree(project_key)
  107. @mcp.tool()
  108. def list_systems(
  109. project_key: str,
  110. page_size: int = 100,
  111. page_num: int = 1,
  112. system_type_id: int = 0,
  113. show_below: bool = True,
  114. ) -> Any:
  115. """List systems by system type."""
  116. payload = api_list_systems(
  117. project_key,
  118. page_size=page_size,
  119. page_num=page_num,
  120. system_type_id=system_type_id,
  121. show_below=show_below,
  122. )
  123. return _append_next_page_hint(payload, page_num)
  124. @mcp.tool()
  125. def list_device_types(project_key: str) -> Any:
  126. """List all device types."""
  127. return api_list_device_types(project_key)
  128. @mcp.tool()
  129. def list_meter_types(project_key: str) -> Any:
  130. """List all meter types."""
  131. return api_list_meter_types(project_key)
  132. @mcp.tool()
  133. def search_devices(
  134. project_key: str,
  135. page_size: int = 100,
  136. page_num: int = 1,
  137. keyword: str = "",
  138. location_id: int = 0,
  139. show_below: bool = True,
  140. system_ids: list[int] | None = None,
  141. device_type_ids: list[int] | None = None,
  142. ) -> Any:
  143. """Search devices with id-based filters."""
  144. payload = api_search_devices(
  145. project_key,
  146. page_size=page_size,
  147. page_num=page_num,
  148. keyword=keyword,
  149. location_id=location_id,
  150. show_below=show_below,
  151. system_ids=system_ids,
  152. device_type_ids=device_type_ids,
  153. )
  154. return _append_next_page_hint(payload, page_num)
  155. @mcp.tool()
  156. def search_meters(
  157. project_key: str,
  158. page_size: int = 100,
  159. page_num: int = 1,
  160. keyword: str = "",
  161. location_id: int = 0,
  162. show_below: bool = True,
  163. meter_type_id: int = 0,
  164. measurement_location_ids: list[int] | None = None,
  165. measurement_system_ids: list[int] | None = None,
  166. measurement_device_type_ids: list[int] | None = None,
  167. status: int | None = None,
  168. ) -> Any:
  169. """Search meters with id-based filters."""
  170. payload = api_search_meters(
  171. project_key,
  172. page_size=page_size,
  173. page_num=page_num,
  174. keyword=keyword,
  175. location_id=location_id,
  176. show_below=show_below,
  177. meter_type_id=meter_type_id,
  178. measurement_location_ids=measurement_location_ids,
  179. measurement_system_ids=measurement_system_ids,
  180. measurement_device_type_ids=measurement_device_type_ids,
  181. status=status,
  182. )
  183. return _append_next_page_hint(payload, page_num)
  184. @mcp.tool()
  185. def search_points(
  186. project_key: str, id: int, page_size: int = 100, page_num: int = 1
  187. ) -> Any:
  188. """Search points under a meter by meter id."""
  189. payload = api_search_points(
  190. project_key, id=id, page_size=page_size, page_num=page_num
  191. )
  192. return _append_next_page_hint(payload, page_num)
  193. @mcp.tool(name="topology.group_list")
  194. def topology_group_list(project_key: str) -> dict[str, Any]:
  195. """List topology groups to help locate device and meter relationships."""
  196. return list_topology_groups(project_key)
  197. @mcp.tool(name="topology.list")
  198. def topology_list(
  199. project_key: str, group_id: int | None = None, object_type_code: int | None = None
  200. ) -> dict[str, Any]:
  201. """List topologies for checking device and meter hierarchy relationships."""
  202. return list_topologies(
  203. project_key, group_id=group_id, object_type_code=object_type_code
  204. )
  205. @mcp.tool(name="topology.get_node")
  206. def topology_get_node(
  207. project_key: str,
  208. topology_id: int,
  209. node_id: Annotated[
  210. str,
  211. Field(
  212. description=(
  213. "Node ID in the target topology. Use 'root' to resolve the "
  214. "topology root node automatically."
  215. )
  216. ),
  217. ] = "root",
  218. include_siblings: bool = True,
  219. include_children: bool = True,
  220. ) -> dict[str, Any]:
  221. """Get one topology node and its direct parent-child relationships."""
  222. return get_topology_node(
  223. project_key,
  224. topology_id,
  225. node_id,
  226. include_siblings=include_siblings,
  227. include_children=include_children,
  228. )
  229. @mcp.tool(name="topology.find_context")
  230. def topology_find_context(
  231. project_key: str,
  232. entity_type: Annotated[
  233. str,
  234. Field(
  235. description=(
  236. "Entity type for topology relationship lookup. Only 'meter' or "
  237. "'device' are accepted."
  238. )
  239. ),
  240. ],
  241. entity_id: int,
  242. topology_id: int | None = None,
  243. include_siblings: bool = True,
  244. ancestor_depth: int = 5,
  245. descendant_depth: int = 2,
  246. ) -> dict[str, Any]:
  247. """Find topology context for a meter or device, including hierarchy relationships."""
  248. return find_topology_context(
  249. project_key,
  250. entity_type,
  251. entity_id,
  252. topology_id=topology_id,
  253. include_siblings=include_siblings,
  254. ancestor_depth=ancestor_depth,
  255. descendant_depth=descendant_depth,
  256. )
  257. def main() -> None:
  258. host = os.getenv("MCP_HOST", "0.0.0.0").strip() or "0.0.0.0"
  259. port = int(os.getenv("MCP_PORT", "8500"))
  260. path = os.getenv("MCP_PATH", "/mcp").strip() or "/mcp"
  261. mcp.run(transport="http", host=host, port=port, path=path)
  262. if __name__ == "__main__":
  263. main()