test_server_tools.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. from __future__ import annotations
  2. import asyncio
  3. import inspect
  4. import unittest
  5. from unittest.mock import patch
  6. from data_collector_mcp import app, bacnet_server, common_server, modbus_server, s7_server
  7. class ServerToolTests(unittest.TestCase):
  8. def test_app_imports_tool_modules_for_registration(self) -> None:
  9. self.assertIs(app.common_server, common_server)
  10. self.assertIs(app.modbus_server, modbus_server)
  11. self.assertIs(app.s7_server, s7_server)
  12. self.assertIs(app.bacnet_server, bacnet_server)
  13. def test_app_registers_expected_mcp_tools(self) -> None:
  14. tools = asyncio.run(app.mcp.list_tools())
  15. tool_names = {tool.name for tool in tools}
  16. self.assertEqual(
  17. tool_names,
  18. {
  19. "project.list",
  20. "collector.device_list",
  21. "collector.device_connect",
  22. "collector.device_disconnect",
  23. "collector.device_points",
  24. "modbus.raw_read",
  25. "modbus.point_collect_test",
  26. "bacnet.point_collect_test",
  27. "bacnet.point_search",
  28. "bacnet.bbmd_whois",
  29. "s7.raw_read",
  30. "s7.point_collect_test",
  31. "s7.connect_scan",
  32. "collector.modbus_device_create",
  33. "collector.modbus_device_edit",
  34. "collector.modbus_point_create",
  35. "collector.modbus_point_edit",
  36. "collector.s7_device_create",
  37. "collector.s7_device_edit",
  38. "collector.s7_point_create",
  39. "collector.s7_point_edit",
  40. "collector.bacnet_device_create",
  41. "collector.bacnet_device_edit",
  42. "collector.bacnet_point_create",
  43. "collector.bacnet_point_edit",
  44. },
  45. )
  46. def test_project_list_filters_enabled_projects_and_sorts(self) -> None:
  47. with patch(
  48. "data_collector_mcp.common_server.load_projects_config",
  49. return_value=[
  50. {
  51. "project_key": "z-prod",
  52. "project_name": "Prod",
  53. "base_url": "http://gateway.prod",
  54. "data_collector_base_url": "http://collector.prod",
  55. "enabled": True,
  56. },
  57. {
  58. "project_key": "disabled",
  59. "project_name": "Disabled",
  60. "base_url": "http://gateway.disabled",
  61. "data_collector_base_url": "http://collector.disabled",
  62. "enabled": False,
  63. },
  64. {
  65. "project_key": "a-dev",
  66. "project_name": "Dev",
  67. "base_url": "http://gateway.dev",
  68. "data_collector_base_url": "http://collector.dev",
  69. "enabled": True,
  70. },
  71. ],
  72. ):
  73. result = common_server.project_list()
  74. self.assertEqual(
  75. result,
  76. {
  77. "projects": [
  78. {
  79. "project_key": "a-dev",
  80. "project_name": "Dev",
  81. },
  82. {
  83. "project_key": "z-prod",
  84. "project_name": "Prod",
  85. },
  86. ],
  87. "total": 2,
  88. },
  89. )
  90. for project in result["projects"]:
  91. self.assertNotIn("base_url", project)
  92. self.assertNotIn("data_collector_base_url", project)
  93. def test_common_device_tools_forward_to_api(self) -> None:
  94. with patch("data_collector_mcp.common_server.api_list_devices", return_value={"state": 0}) as list_devices:
  95. self.assertEqual(common_server.collector_device_list("dev-01", num_points=True), {"state": 0})
  96. list_devices.assert_called_once_with("dev-01", num_points=True)
  97. with patch("data_collector_mcp.common_server.api_connect_device", return_value={"state": 0}) as connect:
  98. self.assertEqual(
  99. common_server.collector_device_connect("dev-01", device_id=1, device_type="s7"),
  100. {"state": 0},
  101. )
  102. connect.assert_called_once_with("dev-01", device_id=1, device_type="s7")
  103. with patch("data_collector_mcp.common_server.api_disconnect_device", return_value={"state": 0}) as disconnect:
  104. self.assertEqual(
  105. common_server.collector_device_disconnect("dev-01", device_id=1, device_type="modbus"),
  106. {"state": 0},
  107. )
  108. disconnect.assert_called_once_with("dev-01", device_id=1, device_type="modbus")
  109. with patch("data_collector_mcp.common_server.api_list_device_points", return_value={"state": 0}) as points:
  110. self.assertEqual(
  111. common_server.collector_device_points("dev-01", device_id=1, device_type="s7", group_id=2),
  112. {"state": 0},
  113. )
  114. points.assert_called_once_with("dev-01", device_id=1, device_type="s7", group_id=2)
  115. def test_modbus_device_create_accepts_batch_devices(self) -> None:
  116. signature = inspect.signature(modbus_server.collector_modbus_device_create)
  117. self.assertIn("devices", signature.parameters)
  118. devices = [
  119. {
  120. "name": "modbus_tcp_1",
  121. "device_type": 1,
  122. "ip": "127.0.0.1",
  123. "port": 5502,
  124. "slave_id": 1,
  125. "byte_order": 1,
  126. "word_order": 1,
  127. "address_base": 0,
  128. "group_id": 10,
  129. }
  130. ]
  131. with patch("data_collector_mcp.modbus_server.api_create_modbus_devices", return_value={"state": 0}) as api_create:
  132. result = modbus_server.collector_modbus_device_create(
  133. project_key="dev-01",
  134. devices=devices,
  135. )
  136. self.assertEqual(result, {"state": 0})
  137. api_create.assert_called_once_with("dev-01", devices)
  138. def test_modbus_raw_read_forwards_to_api(self) -> None:
  139. read = {"function_code": 3, "address": 0, "quantity": 4}
  140. with patch("data_collector_mcp.modbus_server.api_modbus_raw_read", return_value={"code": 0}) as raw_read:
  141. result = modbus_server.modbus_raw_read(
  142. project_key="dev-01",
  143. ip="192.168.1.10",
  144. port=502,
  145. slave_id=1,
  146. read=read,
  147. word_byte_order="DCBA",
  148. address_base=1,
  149. )
  150. self.assertEqual(result, {"code": 0})
  151. raw_read.assert_called_once_with(
  152. "dev-01",
  153. ip="192.168.1.10",
  154. port=502,
  155. slave_id=1,
  156. read=read,
  157. device_type="ModbusTCP",
  158. word_byte_order="DCBA",
  159. address_base=1,
  160. )
  161. def test_modbus_point_create_accepts_batch_points(self) -> None:
  162. points = [
  163. {
  164. "device_id": 1,
  165. "name": "temperature",
  166. "address": 10,
  167. "type": "uint16",
  168. "func_code": 3,
  169. "scale_ratio": 2,
  170. "value_offset": 1,
  171. "group_id": 5,
  172. "invalid_values": "-9999",
  173. "valid_range_start": 0,
  174. "valid_range_end": 100,
  175. }
  176. ]
  177. with patch("data_collector_mcp.modbus_server.api_create_modbus_points", return_value={"state": 0}) as api_create:
  178. result = modbus_server.collector_modbus_point_create(project_key="dev-01", points=points)
  179. self.assertEqual(result, {"state": 0})
  180. api_create.assert_called_once_with("dev-01", points)
  181. def test_modbus_device_edit_uses_explicit_parameters(self) -> None:
  182. signature = inspect.signature(modbus_server.collector_modbus_device_edit)
  183. self.assertNotIn("payload", signature.parameters)
  184. with patch("data_collector_mcp.modbus_server.api_edit_modbus_device", return_value={"state": 0}) as api_edit:
  185. result = modbus_server.collector_modbus_device_edit(
  186. project_key="dev-01",
  187. ori_id=1,
  188. name="modbus_tcp_edited",
  189. device_type=1,
  190. ip="127.0.0.1",
  191. port=5502,
  192. slave_id=1,
  193. byte_order=2,
  194. word_order=2,
  195. address_offset=1,
  196. device_group_id=10,
  197. )
  198. self.assertEqual(result, {"state": 0})
  199. api_edit.assert_called_once_with(
  200. "dev-01",
  201. {
  202. "ori_id": 1,
  203. "name": "modbus_tcp_edited",
  204. "device_type": 1,
  205. "ip": "127.0.0.1",
  206. "port": 5502,
  207. "slave_id": 1,
  208. "byte_order": 2,
  209. "word_order": 2,
  210. "serial_port": "",
  211. "timeout": 3,
  212. "is_persistent": False,
  213. "baud_rate": 0,
  214. "data_bit": 0,
  215. "parity": 0,
  216. "stop_bit": 0,
  217. "mode": 0,
  218. "address_offset": 1,
  219. "retry_times": 0,
  220. "device_group_id": 10,
  221. "alarm_interval": 90,
  222. "collect_interval": 5,
  223. },
  224. )
  225. def test_modbus_point_edit_uses_explicit_parameters_with_func_code(self) -> None:
  226. signature = inspect.signature(modbus_server.collector_modbus_point_edit)
  227. self.assertNotIn("payload", signature.parameters)
  228. with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
  229. result = modbus_server.collector_modbus_point_edit(
  230. project_key="dev-01",
  231. ori_id=101,
  232. name="holding_register_uint16_edited",
  233. address=10,
  234. data_type="uint16",
  235. func_code=3,
  236. point_id="HR_UINT16_EDITED",
  237. scale_ratio=2,
  238. value_offset=1,
  239. group_id=5,
  240. invalid_values="-9999",
  241. valid_range_start=0,
  242. valid_range_end=100,
  243. )
  244. self.assertEqual(result, {"state": 0})
  245. api_edit.assert_called_once_with(
  246. "dev-01",
  247. {
  248. "ori_id": 101,
  249. "name": "holding_register_uint16_edited",
  250. "address": 10,
  251. "type": "uint16",
  252. "point_id": "HR_UINT16_EDITED",
  253. "scale_ratio": 2,
  254. "value_offset": 1,
  255. "group_id": 5,
  256. "invalid_values": "-9999",
  257. "valid_range_start": 0,
  258. "valid_range_end": 100,
  259. "bit": 0,
  260. "describe": "",
  261. "func_code": 3,
  262. },
  263. )
  264. def test_modbus_point_edit_uses_register_type_when_func_code_is_zero(self) -> None:
  265. with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
  266. modbus_server.collector_modbus_point_edit(
  267. project_key="dev-01",
  268. ori_id=101,
  269. name="holding_register_uint16_edited",
  270. address=10,
  271. data_type="uint16",
  272. register_type="holding_register",
  273. )
  274. payload = api_edit.call_args.args[1]
  275. self.assertEqual(payload["register_type"], "holding_register")
  276. self.assertNotIn("func_code", payload)
  277. def test_s7_device_create_accepts_batch_devices(self) -> None:
  278. signature = inspect.signature(s7_server.collector_s7_device_create)
  279. self.assertIn("devices", signature.parameters)
  280. devices = [{"name": "s7_1200_1", "ip": "127.0.0.1", "rock": 0, "slot": 1, "tsap_conn_type": "OP"}]
  281. with patch("data_collector_mcp.s7_server.api_create_s7_devices", return_value={"state": 0}) as api_create:
  282. result = s7_server.collector_s7_device_create(
  283. project_key="dev-01",
  284. devices=devices,
  285. )
  286. self.assertEqual(result, {"state": 0})
  287. api_create.assert_called_once_with(
  288. "dev-01",
  289. [{"name": "s7_1200_1", "ip": "127.0.0.1", "rock": 0, "slot": 1, "tsap_conn_type": "OP"}],
  290. )
  291. def test_s7_point_create_accepts_batch_points(self) -> None:
  292. points = [{"device_id": 3, "name": "db_real", "address": "1.10", "type": "REAL", "register_area": "DB"}]
  293. with patch("data_collector_mcp.s7_server.api_create_s7_points", return_value={"state": 0}) as api_create:
  294. result = s7_server.collector_s7_point_create(project_key="dev-01", points=points)
  295. self.assertEqual(result, {"state": 0})
  296. api_create.assert_called_once_with("dev-01", points)
  297. def test_s7_device_edit_uses_explicit_parameters(self) -> None:
  298. signature = inspect.signature(s7_server.collector_s7_device_edit)
  299. self.assertNotIn("payload", signature.parameters)
  300. with patch("data_collector_mcp.s7_server.api_edit_s7_device", return_value={"state": 0}) as api_edit:
  301. result = s7_server.collector_s7_device_edit(
  302. project_key="dev-01",
  303. ori_id=3,
  304. name="s7_edited",
  305. ip="127.0.0.1",
  306. rock=0,
  307. slot=1,
  308. tsap_conn_type="OP",
  309. )
  310. self.assertEqual(result, {"state": 0})
  311. api_edit.assert_called_once_with(
  312. "dev-01",
  313. {
  314. "ori_id": 3,
  315. "name": "s7_edited",
  316. "ip": "127.0.0.1",
  317. "rock": 0,
  318. "slot": 1,
  319. "port": 102,
  320. "device_type": 1,
  321. "tsap_conn_type": "OP",
  322. "is_persistent": False,
  323. "device_group_id": 0,
  324. "timeout": 3,
  325. "alarm_interval": 90,
  326. "collect_interval": 5,
  327. },
  328. )
  329. def test_s7_point_edit_uses_register_area_when_register_type_is_zero(self) -> None:
  330. signature = inspect.signature(s7_server.collector_s7_point_edit)
  331. self.assertNotIn("payload", signature.parameters)
  332. with patch("data_collector_mcp.s7_server.api_edit_s7_point", return_value={"state": 0}) as api_edit:
  333. s7_server.collector_s7_point_edit(
  334. project_key="dev-01",
  335. ori_id=101,
  336. device_id=3,
  337. name="db_real",
  338. address="1.10",
  339. data_type="float32",
  340. register_area="DB",
  341. point_id="DB_REAL",
  342. )
  343. payload = api_edit.call_args.args[1]
  344. self.assertEqual(payload["register_area"], "DB")
  345. self.assertNotIn("register_type", payload)
  346. def test_s7_gateway_tools_forward_to_api(self) -> None:
  347. with patch("data_collector_mcp.s7_server.api_s7_raw_read", return_value={"code": 0}) as raw_read:
  348. self.assertEqual(
  349. s7_server.s7_raw_read(
  350. project_key="dev-01",
  351. ip="192.168.1.10",
  352. rock=0,
  353. slot=1,
  354. read={"area": "DB", "db": 1, "start": 0, "size": 4},
  355. ),
  356. {"code": 0},
  357. )
  358. raw_read.assert_called_once_with(
  359. "dev-01",
  360. ip="192.168.1.10",
  361. rock=0,
  362. slot=1,
  363. read={"area": "DB", "db": 1, "start": 0, "size": 4},
  364. device_type="S7-1200",
  365. port=102,
  366. tsap_conn_type=None,
  367. )
  368. with patch("data_collector_mcp.s7_server.api_s7_point_collect_test", return_value={"code": 0}) as point_test:
  369. self.assertEqual(
  370. s7_server.s7_point_collect_test(
  371. project_key="dev-01",
  372. ip="192.168.1.10",
  373. port=1102,
  374. rock=0,
  375. slot=1,
  376. tsap_conn_type="BASIC",
  377. points=[{"area": "M", "start": 0, "type": "bool"}],
  378. ),
  379. {"code": 0},
  380. )
  381. point_test.assert_called_once_with(
  382. "dev-01",
  383. ip="192.168.1.10",
  384. rock=0,
  385. slot=1,
  386. points=[{"area": "M", "start": 0, "type": "bool"}],
  387. device_type="S7-1200",
  388. port=1102,
  389. tsap_conn_type="BASIC",
  390. )
  391. with patch("data_collector_mcp.s7_server.api_s7_connect_scan", return_value={"code": 0}) as connect_scan:
  392. self.assertEqual(s7_server.s7_connect_scan("dev-01", ip="192.168.1.10"), {"code": 0})
  393. connect_scan.assert_called_once_with("dev-01", ip="192.168.1.10")
  394. def test_bacnet_gateway_tools_forward_to_api(self) -> None:
  395. with patch(
  396. "data_collector_mcp.bacnet_server.api_bacnet_point_collect_test",
  397. return_value={"code": 0},
  398. ) as point_test:
  399. self.assertEqual(
  400. bacnet_server.bacnet_point_collect_test(
  401. project_key="dev-01",
  402. ip="192.168.1.20",
  403. bacnet_device_id=12345,
  404. port=47809,
  405. points=[{"object_type": "AnalogInput", "object_id": 1}],
  406. ),
  407. {"code": 0},
  408. )
  409. point_test.assert_called_once_with(
  410. "dev-01",
  411. ip="192.168.1.20",
  412. bacnet_device_id=12345,
  413. port=47809,
  414. points=[{"object_type": "AnalogInput", "object_id": 1}],
  415. )
  416. with patch(
  417. "data_collector_mcp.bacnet_server.api_bacnet_point_search",
  418. return_value={"code": 0},
  419. ) as point_search:
  420. self.assertEqual(
  421. bacnet_server.bacnet_point_search(
  422. project_key="dev-01",
  423. ip="192.168.1.20",
  424. bacnet_device_id=12345,
  425. ),
  426. {"code": 0},
  427. )
  428. point_search.assert_called_once_with(
  429. "dev-01",
  430. ip="192.168.1.20",
  431. bacnet_device_id=12345,
  432. port=47808,
  433. )
  434. with patch(
  435. "data_collector_mcp.bacnet_server.api_bacnet_bbmd_whois",
  436. return_value={"code": 0},
  437. ) as bbmd_whois:
  438. self.assertEqual(bacnet_server.bacnet_bbmd_whois("dev-01"), {"code": 0})
  439. bbmd_whois.assert_called_once_with("dev-01")
  440. def test_bacnet_collector_tools_forward_to_api(self) -> None:
  441. devices = [{"name": "bacnet_1", "ip": "192.168.1.20", "bacnet_device_id": 12345}]
  442. with patch(
  443. "data_collector_mcp.bacnet_server.api_create_bacnet_devices",
  444. return_value={"state": 0},
  445. ) as api_create:
  446. result = bacnet_server.collector_bacnet_device_create(project_key="dev-01", devices=devices)
  447. self.assertEqual(result, {"state": 0})
  448. api_create.assert_called_once_with("dev-01", devices)
  449. with patch(
  450. "data_collector_mcp.bacnet_server.api_edit_bacnet_device",
  451. return_value={"state": 0},
  452. ) as api_edit:
  453. result = bacnet_server.collector_bacnet_device_edit(
  454. project_key="dev-01",
  455. ori_id=9,
  456. name="bacnet_edited",
  457. ip="192.168.1.21",
  458. bacnet_device_id=54321,
  459. device_group_id=10,
  460. )
  461. self.assertEqual(result, {"state": 0})
  462. api_edit.assert_called_once_with(
  463. "dev-01",
  464. {
  465. "ori_id": 9,
  466. "name": "bacnet_edited",
  467. "ip": "192.168.1.21",
  468. "bacnet_device_id": 54321,
  469. "port": 47808,
  470. "bacnet_net": 0,
  471. "asp_ip": "",
  472. "is_persistent": False,
  473. "device_group_id": 10,
  474. "timeout": 3,
  475. "alarm_interval": 90,
  476. "collect_interval": 5,
  477. },
  478. )
  479. points = [
  480. {
  481. "device_id": 9,
  482. "name": "zone_temperature",
  483. "object_type": "AnalogInput",
  484. "object_id": 1,
  485. "scale_ratio": 2,
  486. "value_offset": 1,
  487. "group_id": 5,
  488. "invalid_values": "-9999",
  489. "valid_range_start": 0,
  490. "valid_range_end": 100,
  491. }
  492. ]
  493. with patch(
  494. "data_collector_mcp.bacnet_server.api_create_bacnet_points",
  495. return_value={"state": 0},
  496. ) as api_point_create:
  497. result = bacnet_server.collector_bacnet_point_create(project_key="dev-01", points=points)
  498. self.assertEqual(result, {"state": 0})
  499. api_point_create.assert_called_once_with("dev-01", points)
  500. with patch(
  501. "data_collector_mcp.bacnet_server.api_edit_bacnet_point",
  502. return_value={"state": 0},
  503. ) as api_point_edit:
  504. result = bacnet_server.collector_bacnet_point_edit(
  505. project_key="dev-01",
  506. ori_id=101,
  507. name="zone_temperature_edited",
  508. object_type="AnalogInput",
  509. object_id=1,
  510. point_id="AI_TEMP_EDITED",
  511. )
  512. self.assertEqual(result, {"state": 0})
  513. payload = api_point_edit.call_args.args[1]
  514. self.assertEqual(api_point_edit.call_args.args[0], "dev-01")
  515. self.assertEqual(payload["ori_id"], 101)
  516. self.assertEqual(payload["name"], "zone_temperature_edited")
  517. self.assertEqual(payload["object_type"], "AnalogInput")
  518. self.assertEqual(payload["object_id"], 1)
  519. self.assertEqual(payload["point_id"], "AI_TEMP_EDITED")
  520. with patch(
  521. "data_collector_mcp.bacnet_server.api_edit_bacnet_point",
  522. return_value={"state": 0},
  523. ) as api_point_edit:
  524. bacnet_server.collector_bacnet_point_edit(
  525. project_key="dev-01",
  526. ori_id=101,
  527. name="zone_temperature_edited",
  528. object_type="AnalogInput",
  529. object_id=1,
  530. scale_ratio=2,
  531. value_offset=1,
  532. group_id=5,
  533. invalid_values="-9999",
  534. valid_range_start=0,
  535. valid_range_end=100,
  536. )
  537. payload = api_point_edit.call_args.args[1]
  538. self.assertEqual(payload["scale_ratio"], 2)
  539. self.assertEqual(payload["value_offset"], 1)
  540. self.assertEqual(payload["group_id"], 5)
  541. self.assertEqual(payload["invalid_values"], "-9999")
  542. self.assertEqual(payload["valid_range_start"], 0)
  543. self.assertEqual(payload["valid_range_end"], 100)
  544. if __name__ == "__main__":
  545. unittest.main()