server.py 8.4 KB

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