server.py 9.5 KB

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