@aryacode/dsh-engineering 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/cordis.patch.yml +122 -0
  5. package/locale/en.json +6 -0
  6. package/locale/zh.json +6 -0
  7. package/package.json +77 -3
  8. package/vendor/ftview/README.md +55 -0
  9. package/vendor/ftview/SKILL.md +74 -0
  10. package/vendor/ftview/lib/checks.d.ts +27 -0
  11. package/vendor/ftview/lib/checks.js +39 -0
  12. package/vendor/ftview/lib/core.d.ts +208 -0
  13. package/vendor/ftview/lib/core.js +160 -0
  14. package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
  15. package/vendor/ftview/lib/edit/drafts.js +84 -0
  16. package/vendor/ftview/lib/edit/operations.d.ts +62 -0
  17. package/vendor/ftview/lib/edit/operations.js +126 -0
  18. package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
  19. package/vendor/ftview/lib/edit/prepare.js +133 -0
  20. package/vendor/ftview/lib/edit/tools.d.ts +25 -0
  21. package/vendor/ftview/lib/edit/tools.js +198 -0
  22. package/vendor/ftview/lib/edit.d.ts +30 -0
  23. package/vendor/ftview/lib/edit.js +38 -0
  24. package/vendor/ftview/lib/index.d.ts +31 -0
  25. package/vendor/ftview/lib/index.js +36 -0
  26. package/vendor/ftview/lib/model.d.ts +80 -0
  27. package/vendor/ftview/lib/model.js +267 -0
  28. package/vendor/ftview/lib/session.d.ts +48 -0
  29. package/vendor/ftview/lib/session.js +98 -0
  30. package/vendor/ftview/lib/skill.d.ts +29 -0
  31. package/vendor/ftview/lib/skill.js +43 -0
  32. package/vendor/ftview/lib/studio/discover.d.ts +36 -0
  33. package/vendor/ftview/lib/studio/discover.js +87 -0
  34. package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
  35. package/vendor/ftview/lib/studio/preflight.js +64 -0
  36. package/vendor/ftview/lib/studio/tools.d.ts +15 -0
  37. package/vendor/ftview/lib/studio/tools.js +83 -0
  38. package/vendor/ftview/lib/studio.d.ts +30 -0
  39. package/vendor/ftview/lib/studio.js +37 -0
  40. package/vendor/ftview/lib/tools.d.ts +14 -0
  41. package/vendor/ftview/lib/tools.js +202 -0
  42. package/vendor/l5x/README.md +62 -0
  43. package/vendor/l5x/SKILL.md +81 -0
  44. package/vendor/l5x/lib/approvals.d.ts +50 -0
  45. package/vendor/l5x/lib/approvals.js +93 -0
  46. package/vendor/l5x/lib/client.js +412 -0
  47. package/vendor/l5x/lib/client.js.map +1 -0
  48. package/vendor/l5x/lib/core/errors.d.ts +38 -0
  49. package/vendor/l5x/lib/core/errors.js +48 -0
  50. package/vendor/l5x/lib/core/params.d.ts +49 -0
  51. package/vendor/l5x/lib/core/params.js +39 -0
  52. package/vendor/l5x/lib/core/result.d.ts +46 -0
  53. package/vendor/l5x/lib/core/result.js +21 -0
  54. package/vendor/l5x/lib/core/tool.d.ts +102 -0
  55. package/vendor/l5x/lib/core/tool.js +19 -0
  56. package/vendor/l5x/lib/drafts.d.ts +66 -0
  57. package/vendor/l5x/lib/drafts.js +98 -0
  58. package/vendor/l5x/lib/editing.d.ts +30 -0
  59. package/vendor/l5x/lib/editing.js +42 -0
  60. package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
  61. package/vendor/l5x/lib/engine/acd-edit.js +115 -0
  62. package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
  63. package/vendor/l5x/lib/engine/analysis.js +258 -0
  64. package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
  65. package/vendor/l5x/lib/engine/capabilities.js +40 -0
  66. package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
  67. package/vendor/l5x/lib/engine/change-set.js +446 -0
  68. package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
  69. package/vendor/l5x/lib/engine/configuration.js +42 -0
  70. package/vendor/l5x/lib/engine/details.d.ts +47 -0
  71. package/vendor/l5x/lib/engine/details.js +173 -0
  72. package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
  73. package/vendor/l5x/lib/engine/direct-polling.js +193 -0
  74. package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
  75. package/vendor/l5x/lib/engine/download-review.js +63 -0
  76. package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
  77. package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
  78. package/vendor/l5x/lib/engine/observations.d.ts +41 -0
  79. package/vendor/l5x/lib/engine/observations.js +67 -0
  80. package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
  81. package/vendor/l5x/lib/engine/project-analysis.js +314 -0
  82. package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
  83. package/vendor/l5x/lib/engine/review-evidence.js +49 -0
  84. package/vendor/l5x/lib/engine/review.d.ts +18 -0
  85. package/vendor/l5x/lib/engine/review.js +123 -0
  86. package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
  87. package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
  88. package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
  89. package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
  90. package/vendor/l5x/lib/engine/usage.d.ts +16 -0
  91. package/vendor/l5x/lib/engine/usage.js +92 -0
  92. package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
  93. package/vendor/l5x/lib/engine/xml-regions.js +77 -0
  94. package/vendor/l5x/lib/fs.d.ts +17 -0
  95. package/vendor/l5x/lib/fs.js +36 -0
  96. package/vendor/l5x/lib/index.d.ts +36 -0
  97. package/vendor/l5x/lib/index.js +42 -0
  98. package/vendor/l5x/lib/live.d.ts +48 -0
  99. package/vendor/l5x/lib/live.js +48 -0
  100. package/vendor/l5x/lib/model/aois.d.ts +39 -0
  101. package/vendor/l5x/lib/model/aois.js +112 -0
  102. package/vendor/l5x/lib/model/errors.d.ts +15 -0
  103. package/vendor/l5x/lib/model/errors.js +18 -0
  104. package/vendor/l5x/lib/model/index.d.ts +20 -0
  105. package/vendor/l5x/lib/model/index.js +19 -0
  106. package/vendor/l5x/lib/model/module.d.ts +37 -0
  107. package/vendor/l5x/lib/model/module.js +54 -0
  108. package/vendor/l5x/lib/model/modules.d.ts +24 -0
  109. package/vendor/l5x/lib/model/modules.js +55 -0
  110. package/vendor/l5x/lib/model/parse.d.ts +32 -0
  111. package/vendor/l5x/lib/model/parse.js +116 -0
  112. package/vendor/l5x/lib/model/project.d.ts +15 -0
  113. package/vendor/l5x/lib/model/project.js +31 -0
  114. package/vendor/l5x/lib/model/routines.d.ts +71 -0
  115. package/vendor/l5x/lib/model/routines.js +183 -0
  116. package/vendor/l5x/lib/model/tags.d.ts +51 -0
  117. package/vendor/l5x/lib/model/tags.js +119 -0
  118. package/vendor/l5x/lib/model/types.d.ts +304 -0
  119. package/vendor/l5x/lib/model/types.js +12 -0
  120. package/vendor/l5x/lib/model/udts.d.ts +34 -0
  121. package/vendor/l5x/lib/model/udts.js +81 -0
  122. package/vendor/l5x/lib/model/xml.d.ts +139 -0
  123. package/vendor/l5x/lib/model/xml.js +291 -0
  124. package/vendor/l5x/lib/model/xref.d.ts +28 -0
  125. package/vendor/l5x/lib/model/xref.js +154 -0
  126. package/vendor/l5x/lib/session.d.ts +119 -0
  127. package/vendor/l5x/lib/session.js +236 -0
  128. package/vendor/l5x/lib/skill.d.ts +30 -0
  129. package/vendor/l5x/lib/skill.js +44 -0
  130. package/vendor/l5x/lib/studio-state.d.ts +77 -0
  131. package/vendor/l5x/lib/studio-state.js +79 -0
  132. package/vendor/l5x/lib/studio.d.ts +29 -0
  133. package/vendor/l5x/lib/studio.js +40 -0
  134. package/vendor/l5x/lib/tools/checks.d.ts +19 -0
  135. package/vendor/l5x/lib/tools/checks.js +140 -0
  136. package/vendor/l5x/lib/tools/common.d.ts +41 -0
  137. package/vendor/l5x/lib/tools/common.js +84 -0
  138. package/vendor/l5x/lib/tools/controller.d.ts +21 -0
  139. package/vendor/l5x/lib/tools/controller.js +170 -0
  140. package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
  141. package/vendor/l5x/lib/tools/drafts.js +270 -0
  142. package/vendor/l5x/lib/tools/live.d.ts +21 -0
  143. package/vendor/l5x/lib/tools/live.js +175 -0
  144. package/vendor/l5x/lib/tools/read.d.ts +19 -0
  145. package/vendor/l5x/lib/tools/read.js +245 -0
  146. package/vendor/l5x/lib/tools/references.d.ts +20 -0
  147. package/vendor/l5x/lib/tools/references.js +269 -0
  148. package/vendor/l5x/lib/tools/studio.d.ts +21 -0
  149. package/vendor/l5x/lib/tools/studio.js +193 -0
  150. package/vendor/l5x/poll-worker.mjs +77 -0
  151. package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
  152. package/vendor/l5x/studio-bridge/Program.cs +304 -0
  153. package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
  154. package/vendor/mwi/README.md +60 -0
  155. package/vendor/mwi/SKILL.md +87 -0
  156. package/vendor/mwi/bridge/README.md +8 -0
  157. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
  158. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
  159. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
  160. package/vendor/mwi/bridge/start_bridge.cmd +21 -0
  161. package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
  162. package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
  163. package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
  164. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  165. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  166. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  167. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  168. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  169. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  170. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  171. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  172. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  173. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  174. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  175. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  176. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  177. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  178. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  179. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  180. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  181. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  182. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  183. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  184. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  185. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
  186. package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
  187. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
  188. package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
  189. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
  190. package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
  191. package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
  192. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
  193. package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
  194. package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
  195. package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
  196. package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
  197. package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
  198. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
  199. package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
  200. package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
  201. package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
  202. package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
  203. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
  204. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
  205. package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
  206. package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
  207. package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
  208. package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
  209. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
  210. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
  211. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
  212. package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
  213. package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
  214. package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
  215. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
  216. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
  217. package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
  218. package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
  219. package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
  220. package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
  221. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
  222. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
  223. package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
  224. package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
  225. package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
  226. package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
  227. package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
  228. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
  229. package/vendor/mwi/code/mw_code.py +1258 -0
  230. package/vendor/mwi/legacy/edit-session.js +50 -0
  231. package/vendor/mwi/legacy/fb-insertion.js +74 -0
  232. package/vendor/mwi/legacy/native-code.js +83 -0
  233. package/vendor/mwi/legacy/native-groups.js +75 -0
  234. package/vendor/mwi/legacy/native-structure.js +143 -0
  235. package/vendor/mwi/legacy/native-variables.js +77 -0
  236. package/vendor/mwi/legacy/pou-conversion.js +47 -0
  237. package/vendor/mwi/legacy/pou-package.js +66 -0
  238. package/vendor/mwi/lib/core/bridge.d.ts +71 -0
  239. package/vendor/mwi/lib/core/bridge.js +53 -0
  240. package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
  241. package/vendor/mwi/lib/core/code-engine.js +109 -0
  242. package/vendor/mwi/lib/core/errors.d.ts +37 -0
  243. package/vendor/mwi/lib/core/errors.js +47 -0
  244. package/vendor/mwi/lib/core/params.d.ts +52 -0
  245. package/vendor/mwi/lib/core/params.js +46 -0
  246. package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
  247. package/vendor/mwi/lib/core/ps-bridge.js +127 -0
  248. package/vendor/mwi/lib/core/result.d.ts +45 -0
  249. package/vendor/mwi/lib/core/result.js +20 -0
  250. package/vendor/mwi/lib/core/tool.d.ts +80 -0
  251. package/vendor/mwi/lib/core/tool.js +55 -0
  252. package/vendor/mwi/lib/edit/compare.d.ts +33 -0
  253. package/vendor/mwi/lib/edit/compare.js +70 -0
  254. package/vendor/mwi/lib/edit.d.ts +20 -0
  255. package/vendor/mwi/lib/edit.js +32 -0
  256. package/vendor/mwi/lib/index.d.ts +21 -0
  257. package/vendor/mwi/lib/index.js +33 -0
  258. package/vendor/mwi/lib/read/service.d.ts +117 -0
  259. package/vendor/mwi/lib/read/service.js +229 -0
  260. package/vendor/mwi/lib/read.d.ts +21 -0
  261. package/vendor/mwi/lib/read.js +33 -0
  262. package/vendor/mwi/lib/session/project.d.ts +34 -0
  263. package/vendor/mwi/lib/session/project.js +54 -0
  264. package/vendor/mwi/lib/session/service.d.ts +167 -0
  265. package/vendor/mwi/lib/session/service.js +566 -0
  266. package/vendor/mwi/lib/session/stage.d.ts +84 -0
  267. package/vendor/mwi/lib/session/stage.js +187 -0
  268. package/vendor/mwi/lib/session/workspace.d.ts +52 -0
  269. package/vendor/mwi/lib/session/workspace.js +90 -0
  270. package/vendor/mwi/lib/skill.d.ts +36 -0
  271. package/vendor/mwi/lib/skill.js +56 -0
  272. package/vendor/mwi/lib/tools/edit.d.ts +20 -0
  273. package/vendor/mwi/lib/tools/edit.js +286 -0
  274. package/vendor/mwi/lib/tools/read.d.ts +19 -0
  275. package/vendor/mwi/lib/tools/read.js +161 -0
  276. package/vendor/mwi/lib/tools/session.d.ts +20 -0
  277. package/vendor/mwi/lib/tools/session.js +166 -0
  278. package/vendor/mwi/lib/tools/verify.d.ts +19 -0
  279. package/vendor/mwi/lib/tools/verify.js +85 -0
  280. package/vendor/mwi/lib/verify.d.ts +17 -0
  281. package/vendor/mwi/lib/verify.js +29 -0
@@ -0,0 +1,1913 @@
1
+ """FastMCP tool surface for offline Yaskawa MotionWorks IEC 3 Pro projects.
2
+
3
+ Run over stdio::
4
+
5
+ motionworks-iec-mcp
6
+
7
+ Tier 1 tools (``mw_*``) are read-only and safe to call while MotionWorks IEC is
8
+ open. They are the majority of this module on purpose: an agent that can read
9
+ the project accurately needs far less help writing to it.
10
+
11
+ Design rules that all tools follow:
12
+
13
+ * Return readable text, not raw JSON dumps. The caller is a language model;
14
+ a compact labelled report is more useful than a serialised object.
15
+ * Never guess. When something is unsupported (MotionWorks' compressed stream
16
+ container), say so explicitly and name the affected POUs.
17
+ * Resolve ``project`` leniently: full path, ``.mwt`` file, or a bare name.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from mcp.server.fastmcp import FastMCP
27
+
28
+ from . import project as P
29
+ from .errors import MotionWorksError, NotFound, ProjectNotFound, UnsupportedFormat
30
+ from .variables import Variable
31
+
32
+ INSTRUCTIONS = """\
33
+ Read and (in later tiers) safely edit offline Yaskawa MotionWorks IEC 3 Pro
34
+ projects on disk.
35
+
36
+ The project on disk is an expanded directory (containing POE/, C/, src.st1)
37
+ beside a small .mwt wrapper file. POU sources are OLE/CFB compound files: each
38
+ POU stores its variables as text in <POU>V.VB and its body in <POU>.STB
39
+ (Structured Text) or <POU>.GB (graphical ladder/FBD).
40
+
41
+ Critical workflow rules:
42
+ 1. These tools never build. After any edit you MUST open MotionWorks IEC and
43
+ run Build -> Rebuild Project, then Make. Rebuild is required because tmp.sto
44
+ caches are stale after external edits.
45
+ 2. Never trust tmp.sto. It is build output, not source.
46
+ 3. Do not download to a controller or command motion.
47
+ 4. Read mw_get_symbol before referencing any symbol, so you do not invent names.
48
+ 5. Graphical LD/FBD bodies cannot be authored byte-by-byte. Copy a working
49
+ body from a donor POU of the same language instead.
50
+ """
51
+
52
+ #: log_level is raised to WARNING so the SDK does not emit an INFO line to
53
+ #: stderr for every request. MCP uses stdout for the protocol, but noisy stderr
54
+ #: still clutters agent logs and can be mistaken for a problem.
55
+ mcp = FastMCP("motionworks-iec", instructions=INSTRUCTIONS, log_level="WARNING")
56
+
57
+ #: Search root used when a tool is given a bare project name. Overridable so a
58
+ #: user with projects elsewhere does not need to pass full paths everywhere.
59
+ SEARCH_ROOT_ENV = "MOTIONWORKS_MCP_ROOT"
60
+ DEFAULT_SEARCH_ROOT = "Yaskawa"
61
+
62
+
63
+ def _search_root() -> Path:
64
+ configured = os.environ.get(SEARCH_ROOT_ENV)
65
+ if configured:
66
+ return Path(configured)
67
+ return Path(DEFAULT_SEARCH_ROOT)
68
+
69
+
70
+ def _project(project: str | None) -> P.Project:
71
+ """Resolve a project argument, falling back to a sensible default.
72
+
73
+ Resolution order: explicit argument, then an ancestor of the current
74
+ working directory that looks like a project, then the only discovered
75
+ project. This keeps the common case (one project, agent opened in it) free
76
+ of ceremony while still refusing to guess when several are present.
77
+ """
78
+ if project:
79
+ return P.resolve(project, _search_root())
80
+
81
+ # Walk up from cwd looking for a project root.
82
+ cwd = Path.cwd().resolve()
83
+ for candidate in (cwd, *cwd.parents):
84
+ if P.looks_like_project(candidate):
85
+ return P.Project(root=candidate)
86
+
87
+ found = P.discover(_search_root())
88
+ if len(found) == 1:
89
+ return P.Project(root=found[0])
90
+ if not found:
91
+ raise ProjectNotFound(
92
+ f"No project given and none discovered under {_search_root()}. "
93
+ f"Pass the project path, or set {SEARCH_ROOT_ENV}."
94
+ )
95
+ listing = "\n ".join(str(p) for p in found)
96
+ raise ProjectNotFound(
97
+ f"No project given and {len(found)} were discovered; pass one of:\n {listing}"
98
+ )
99
+
100
+
101
+ def _fail(exc: Exception) -> str:
102
+ """Render an error as an actionable message instead of a stack trace."""
103
+ if isinstance(exc, MotionWorksError):
104
+ return f"ERROR: {exc}"
105
+ return f"ERROR ({type(exc).__name__}): {exc}"
106
+
107
+
108
+ def _format_variables(variables: list[Variable], indent: str = " ") -> list[str]:
109
+ """Render declarations grouped by block, with a classification per line."""
110
+ lines: list[str] = []
111
+ section = None
112
+ for variable in variables:
113
+ if variable.section != section:
114
+ section = variable.section
115
+ lines.append(f"{indent}[{section}]")
116
+ flags = []
117
+ if variable.is_external:
118
+ flags.append("external")
119
+ elif variable.is_fb_instance:
120
+ flags.append("fb-instance")
121
+ suffix = f" ({', '.join(flags)})" if flags else ""
122
+ lines.append(f"{indent}{variable.format_line()}{suffix}")
123
+ return lines
124
+
125
+
126
+ def _format_library(entry: str) -> str:
127
+ """Render one ``@LIBRARY.LST`` line as ``[KIND] name (path)``.
128
+
129
+ The list is semicolon-separated as ``kind;path;name;version``. For ``USER``
130
+ entries MotionWorks writes the literal ``LIST`` as the name, so the real
131
+ library name has to come from the final path component.
132
+ """
133
+ parts = entry.split(";")
134
+ kind = parts[0].strip() if parts else "?"
135
+ path = parts[1].strip() if len(parts) > 1 else ""
136
+ name = parts[2].strip() if len(parts) > 2 else ""
137
+ if not name or name.upper() == "LIST":
138
+ # Derive from the directory name, which is the library's real identity.
139
+ name = Path(path).name or name or entry
140
+ return f"[{kind}] {name}" + (f" ({path})" if path else "")
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Tier 1 - discovery
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ @mcp.tool()
149
+ def mw_list_projects(search_root: str | None = None) -> str:
150
+ """List every expanded MotionWorks IEC project found on disk.
151
+
152
+ Call this first when you do not know the project path. Returns each
153
+ project's name, full path, POU count by language, and last-change stamp.
154
+
155
+ Args:
156
+ search_root: Directory to search. Defaults to the configured root.
157
+ """
158
+ root = Path(search_root) if search_root else _search_root()
159
+ try:
160
+ projects = P.describe_all(root)
161
+ except Exception as exc: # noqa: BLE001
162
+ return _fail(exc)
163
+ if not projects:
164
+ return (
165
+ f"No expanded MotionWorks projects found under {root}.\n"
166
+ f"A project directory must contain a POE subdirectory. "
167
+ f"Set {SEARCH_ROOT_ENV} if your projects live elsewhere."
168
+ )
169
+ lines = [f"{len(projects)} project(s) under {root}:"]
170
+ for entry in projects:
171
+ langs = ", ".join(f"{k}:{v}" for k, v in sorted(entry["languages"].items()))
172
+ lines.append(f"\n{entry['name']}")
173
+ lines.append(f" path : {entry['root']}")
174
+ lines.append(f" POUs : {entry['pou_count']} ({langs})")
175
+ lines.append(f" last change : {entry['last_change']}")
176
+ return "\n".join(lines)
177
+
178
+
179
+ @mcp.tool()
180
+ def mw_project_summary(project: str | None = None) -> str:
181
+ """Report structure for one project: POUs, tasks, libraries, and files.
182
+
183
+ Use this to orient yourself before reading or editing: it shows the task
184
+ assignments and library references that constrain what code can legally do.
185
+
186
+ Args:
187
+ project: Project path, .mwt file, or bare project name.
188
+ """
189
+ try:
190
+ proj = _project(project)
191
+ info = proj.inventory()
192
+ except Exception as exc: # noqa: BLE001
193
+ return _fail(exc)
194
+
195
+ lines = [
196
+ f"Project : {info['name']}",
197
+ f"Root : {info['root']}",
198
+ f"MWT wrapper : {info['mwt']}",
199
+ f"Last change : {info['last_change']}",
200
+ f"Resource src : {info['resource_source']}",
201
+ f"POUs : {info['pou_count']} "
202
+ f"({', '.join(f'{k}:{v}' for k, v in sorted(info['languages'].items()))})",
203
+ ]
204
+
205
+ tasks: dict[str, list[str]] = info["tasks"] # type: ignore[assignment]
206
+ lines.append(f"\nTasks ({len(tasks)}):")
207
+ if tasks:
208
+ for task, programs in tasks.items():
209
+ assigned = ", ".join(programs) if programs else "<none>"
210
+ lines.append(f" {task:12} <- {assigned}")
211
+ else:
212
+ lines.append(" <none recorded in NODES.LST>")
213
+
214
+ libraries: list[str] = info["libraries"] # type: ignore[assignment]
215
+ lines.append(f"\nLibraries ({len(libraries)}):")
216
+ for entry in libraries:
217
+ lines.append(f" {_format_library(entry)}")
218
+
219
+ streams: dict[str, int] = info["tree_streams"] # type: ignore[assignment]
220
+ if streams:
221
+ lines.append(f"\nProject src.st1 streams: {streams}")
222
+
223
+ lines.append("\nPOUs:")
224
+ for pou in proj.pous():
225
+ streams_n = len(pou.stream_names())
226
+ lines.append(
227
+ f" {pou.name:24} {pou.language() or '?':3} "
228
+ f"{streams_n} streams {pou.directory}"
229
+ )
230
+ return "\n".join(lines)
231
+
232
+
233
+ @mcp.tool()
234
+ def mw_list_pous(project: str | None = None) -> str:
235
+ """List the POUs in a project with language and body stream information.
236
+
237
+ Args:
238
+ project: Project path, .mwt file, or bare project name.
239
+ """
240
+ try:
241
+ proj = _project(project)
242
+ except Exception as exc: # noqa: BLE001
243
+ return _fail(exc)
244
+
245
+ assignments = proj.task_assignments()
246
+ owner: dict[str, list[str]] = {}
247
+ for task, programs in assignments.items():
248
+ for program in programs:
249
+ owner.setdefault(program.casefold(), []).append(task)
250
+
251
+ lines = [f"POUs in {proj.name} ({len(proj.pous())}):"]
252
+ for pou in proj.pous():
253
+ body = pou.body_stream()
254
+ tasks = owner.get(pou.name.casefold(), [])
255
+ lines.append(f"\n{pou.name}")
256
+ lines.append(f" language : {body[1] if body else '?'}")
257
+ lines.append(f" body : {body[0] if body else '<none found>'}")
258
+ lines.append(f" tasks : {', '.join(tasks) if tasks else '<unassigned>'}")
259
+ try:
260
+ table = pou.declarations()
261
+ lines.append(f" variables: {len(table)}")
262
+ except UnsupportedFormat as exc:
263
+ lines.append(f" variables: UNREADABLE - {exc}")
264
+ return "\n".join(lines)
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Tier 1 - variables
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ @mcp.tool()
273
+ def mw_get_variables(
274
+ pou: str | None = None,
275
+ project: str | None = None,
276
+ ) -> str:
277
+ """Read variable declarations for one POU, or the project's globals.
278
+
279
+ Omit ``pou`` to read ``VAR_GLOBAL`` declarations from the resource. Each
280
+ variable is reported with its block (VAR / VAR_EXTERNAL), address, type,
281
+ default value, description, and a classification:
282
+
283
+ * ``external`` - declared VAR_EXTERNAL: owned by another POU or globally
284
+ * ``fb-instance`` - a function-block instance (stateful, needs .CLK etc.)
285
+ * otherwise - a local value owned by this POU
286
+
287
+ Args:
288
+ pou: POU name. Omit for global variables.
289
+ project: Project path, .mwt file, or bare project name.
290
+ """
291
+ try:
292
+ proj = _project(project)
293
+ if pou:
294
+ table = proj.pou(pou).declarations()
295
+ title = f"Variables of POU {proj.pou(pou).name} in {proj.name}"
296
+ else:
297
+ table = proj.global_variables()
298
+ title = f"Global variables of {proj.name}"
299
+ except Exception as exc: # noqa: BLE001
300
+ return _fail(exc)
301
+
302
+ lines = [f"{title} ({len(table)} variables)"]
303
+ if table.source_stream:
304
+ lines.append(f"Source stream: {table.source_stream}")
305
+ for note in table.warnings:
306
+ lines.append(f"WARNING: {note}")
307
+ lines.append("")
308
+ lines.extend(_format_variables(table.variables))
309
+ if not table.variables:
310
+ lines.append(" <no variables>")
311
+ return "\n".join(lines)
312
+
313
+
314
+ @mcp.tool()
315
+ def mw_find_symbol(symbol: str, project: str | None = None) -> str:
316
+ """Find every declaration of a symbol across the whole project.
317
+
318
+ Use this before referencing any symbol so you do not invent a name that does
319
+ not exist, and to discover which POU owns a symbol you need.
320
+
321
+ Args:
322
+ symbol: Exact symbol name (case-insensitive).
323
+ project: Project path, .mwt file, or bare project name.
324
+ """
325
+ try:
326
+ proj = _project(project)
327
+ found = P.find_symbol(proj, symbol)
328
+ except Exception as exc: # noqa: BLE001
329
+ return _fail(exc)
330
+
331
+ if not found:
332
+ index = P.symbol_index(proj)
333
+ close = [n for n in index if symbol.casefold() in n.casefold()][:15]
334
+ lines = [f"Symbol {symbol!r} is NOT declared anywhere in {proj.name}."]
335
+ if close:
336
+ lines.append("Names containing that text:")
337
+ lines.extend(f" {n}" for n in close)
338
+ else:
339
+ lines.append(
340
+ "It may come from a library (see mw_project_summary for the "
341
+ "library list) or from device I/O configuration."
342
+ )
343
+ return "\n".join(lines)
344
+
345
+ lines = [f"{symbol!r} declared {len(found)} time(s) in {proj.name}:"]
346
+ for variable in found:
347
+ lines.append(f" [{variable.section}] {variable.format_line()}")
348
+ if len(found) > 1:
349
+ lines.append(
350
+ "\nNOTE: multiple declarations exist. MotionWorks requires the "
351
+ "owner to hold a real VAR and every other POU to use VAR_EXTERNAL."
352
+ )
353
+ return "\n".join(lines)
354
+
355
+
356
+ @mcp.tool()
357
+ def mw_symbol_index(project: str | None = None, limit: int = 400) -> str:
358
+ """List every declared symbol and where it is declared.
359
+
360
+ This is a compact cross-reference. It does not include which lines of code
361
+ use a symbol, only where each symbol is declared.
362
+
363
+ Args:
364
+ project: Project path, .mwt file, or bare project name.
365
+ limit: Maximum symbols to list.
366
+ """
367
+ try:
368
+ proj = _project(project)
369
+ index = P.symbol_index(proj)
370
+ except Exception as exc: # noqa: BLE001
371
+ return _fail(exc)
372
+
373
+ lines = [f"{len(index)} declared symbols in {proj.name}:"]
374
+ for name in sorted(index, key=str.casefold):
375
+ where = ", ".join(index[name])
376
+ lines.append(f" {name:34} {where}")
377
+ if len(lines) > limit:
378
+ lines.append(f" ... truncated at {limit} lines")
379
+ break
380
+ return "\n".join(lines)
381
+
382
+
383
+ @mcp.tool()
384
+ def mw_library_usage(project: str | None = None, limit: int = 60) -> str:
385
+ """Show which function-block and data types the project actually uses.
386
+
387
+ Use this to find which library a block comes from, and to pick a donor POU
388
+ that already instantiates a block correctly.
389
+
390
+ Args:
391
+ project: Project path, .mwt file, or bare project name.
392
+ limit: Maximum type entries to list.
393
+ """
394
+ try:
395
+ proj = _project(project)
396
+ usage = P.library_usage(proj)
397
+ except Exception as exc: # noqa: BLE001
398
+ return _fail(exc)
399
+
400
+ lines = [f"Declared types in {proj.name} ({len(usage)} distinct):"]
401
+ for name, count in list(usage.items())[:limit]:
402
+ lines.append(f" {count:3} x {name}")
403
+ if len(usage) > limit:
404
+ lines.append(f" ... {len(usage) - limit} more")
405
+ return "\n".join(lines)
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Tier 1 - code bodies
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ @mcp.tool()
414
+ def mw_get_st_body(pou: str, project: str | None = None) -> str:
415
+ """Read the Structured Text body of a POU.
416
+
417
+ Returns an error for graphical (LD/FBD) POUs, whose bodies are proprietary
418
+ binary and are not text; use mw_pou_streams to inspect those instead.
419
+
420
+ Args:
421
+ pou: POU name.
422
+ project: Project path, .mwt file, or bare project name.
423
+ """
424
+ try:
425
+ proj = _project(project)
426
+ info = proj.pou(pou)
427
+ language = info.language()
428
+ if language is None:
429
+ return f"ERROR: POU {pou!r} has no recognisable body stream."
430
+ if language != "ST":
431
+ return (
432
+ f"POU {info.name!r} is {language} (graphical), not Structured Text.\n"
433
+ f"Its body is the binary stream {info.body_stream()[0]!r} and cannot "
434
+ f"be read or edited as text. Use mw_pou_streams to inspect its "
435
+ f"structure, and copy a known-good body from a donor POU rather "
436
+ f"than authoring LD bytes."
437
+ )
438
+ body = info.st_body()
439
+ except Exception as exc: # noqa: BLE001
440
+ return _fail(exc)
441
+
442
+ header = (
443
+ f"--- ST body of {info.name} in {proj.name} "
444
+ f"({len(body or '')} bytes) ---"
445
+ )
446
+ return f"{header}\n{body or ''}"
447
+
448
+
449
+ @mcp.tool()
450
+ def mw_pou_streams(pou: str, project: str | None = None) -> str:
451
+ """List the CFB streams inside a POU's src.st1 and their byte sizes.
452
+
453
+ Useful for graphical POUs, where the .GB body stream size tells you how much
454
+ logic is in there, and for confirming which streams an edit will touch.
455
+
456
+ Args:
457
+ pou: POU name.
458
+ project: Project path, .mwt file, or bare project name.
459
+ """
460
+ try:
461
+ proj = _project(project)
462
+ info = proj.pou(pou)
463
+ source = info.source()
464
+ summary = source.summary()
465
+ except Exception as exc: # noqa: BLE001
466
+ return _fail(exc)
467
+
468
+ body = info.body_stream()
469
+ lines = [
470
+ f"Streams in {info.name}/src.st1 ({info.source_path}):",
471
+ ]
472
+ for name, size in summary.items():
473
+ role = ""
474
+ upper = name.upper()
475
+ if upper.endswith("T.TXT"):
476
+ role = "worksheet comments"
477
+ elif upper.endswith("V.VGR"):
478
+ role = "binary variable grid"
479
+ elif upper.endswith("V.VB"):
480
+ role = "text variable declarations"
481
+ elif upper.endswith(".STB"):
482
+ role = "Structured Text body"
483
+ elif upper.endswith(".GB"):
484
+ role = "graphical LD/FBD body"
485
+ elif upper.endswith(".SN"):
486
+ role = "compression digest sidecar"
487
+ lines.append(f" {name:34} {size:>8} bytes {role}")
488
+ lines.append(f"\nLanguage: {body[1] if body else '?'}")
489
+ return "\n".join(lines)
490
+
491
+
492
+ @mcp.tool()
493
+ def mw_get_pou_comments(pou: str, project: str | None = None) -> str:
494
+ """Read the worksheet comment stream (``<POU>T.TXT``) of a POU.
495
+
496
+ MotionWorks stores free-text worksheet notes here, separate from the body.
497
+
498
+ Args:
499
+ pou: POU name.
500
+ project: Project path, .mwt file, or bare project name.
501
+ """
502
+ try:
503
+ proj = _project(project)
504
+ info = proj.pou(pou)
505
+ name = info.comment_stream_name()
506
+ if name is None:
507
+ return f"POU {info.name!r} has no T.TXT comment stream."
508
+ raw = info.source().read_stream(name)
509
+ except Exception as exc: # noqa: BLE001
510
+ return _fail(exc)
511
+ return f"--- comments of {info.name} ({len(raw)} bytes) ---\n{raw.decode('latin1')}"
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Tier 1 - integrity
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ @mcp.tool()
520
+ def mw_snapshot(
521
+ output: str | None = None,
522
+ project: str | None = None,
523
+ include_generated: bool = True,
524
+ ) -> str:
525
+ """Hash every file in a project and write a JSON manifest.
526
+
527
+ Take a snapshot BEFORE any change so you can prove exactly what was touched
528
+ afterwards with mw_diff_snapshot. This matters because MotionWorks rewrites
529
+ unrelated state on save.
530
+
531
+ Args:
532
+ output: Manifest path. Defaults to <project>/.motionworks-snapshot.json.
533
+ project: Project path, .mwt file, or bare project name.
534
+ include_generated: Include tmp.sto build caches and .sn sidecars.
535
+ """
536
+ try:
537
+ from .snapshot import make_manifest, write_manifest
538
+
539
+ proj = _project(project)
540
+ target = Path(output) if output else proj.root / ".motionworks-snapshot.json"
541
+ manifest = make_manifest(
542
+ proj.root, exclude=target, include_generated=include_generated
543
+ )
544
+ write_manifest(manifest, target)
545
+ except Exception as exc: # noqa: BLE001
546
+ return _fail(exc)
547
+
548
+ files: dict[str, str] = manifest["files"] # type: ignore[assignment]
549
+ return (
550
+ f"Snapshot of {proj.name} written.\n"
551
+ f" manifest : {target}\n"
552
+ f" files : {len(files)}\n"
553
+ f" created : {manifest['created']}\n"
554
+ f"Use mw_diff_snapshot with this manifest to see what changed."
555
+ )
556
+
557
+
558
+ @mcp.tool()
559
+ def mw_diff_snapshot(manifest: str, project: str | None = None) -> str:
560
+ """Report files added, changed, or removed since a snapshot was taken.
561
+
562
+ Args:
563
+ manifest: Path to a manifest written by mw_snapshot.
564
+ project: Optional project root override; defaults to the manifest's base.
565
+ """
566
+ try:
567
+ from .snapshot import diff_manifest, load_manifest
568
+
569
+ data = load_manifest(Path(manifest))
570
+ base = Path(project) if project else None
571
+ result = diff_manifest(data, base)
572
+ except Exception as exc: # noqa: BLE001
573
+ return _fail(exc)
574
+
575
+ total = sum(len(v) for v in result.values())
576
+ if not total:
577
+ return (
578
+ f"No differences: the tree matches the snapshot exactly "
579
+ f"({len(data['files'])} files, taken {data['created']})."
580
+ )
581
+ lines = [f"{total} difference(s) since snapshot taken {data['created']}:"]
582
+ for kind in ("changed", "added", "missing"):
583
+ entries = result[kind]
584
+ if entries:
585
+ lines.append(f"\n{kind.upper()} ({len(entries)}):")
586
+ lines.extend(f" {name}" for name in entries[:60])
587
+ if len(entries) > 60:
588
+ lines.append(f" ... and {len(entries) - 60} more")
589
+ return "\n".join(lines)
590
+
591
+
592
+ @mcp.tool()
593
+ def mw_check_project_open(project: str | None = None) -> str:
594
+ """Check whether MotionWorks IEC is running, which blocks safe editing.
595
+
596
+ Read-only tools work either way. Any Tier 2 write will refuse while the IDE
597
+ is open, because the IDE caches project state and would overwrite or
598
+ contradict an external edit.
599
+
600
+ If it is running, call `mw_close_motionworks` to close it and proceed, or pass
601
+ `close_ide=True` to a write tool to have it close the IDE itself.
602
+
603
+ Args:
604
+ project: Unused; accepted so callers can always pass the project.
605
+ """
606
+ try:
607
+ from . import ide
608
+
609
+ procs = ide.motionworks_running()
610
+ except Exception as exc: # noqa: BLE001
611
+ return _fail(exc)
612
+ if procs:
613
+ listing = "\n".join(
614
+ f" pid {pid} {name}"
615
+ + (" (main)" if name.casefold() in ide.MAIN_PROCESSES else " (helper)")
616
+ for pid, name in procs
617
+ )
618
+ return (
619
+ f"MotionWorks IEC IS RUNNING ({len(procs)} process(es)):\n{listing}\n\n"
620
+ f"Close the IDE before any edit, or call mw_close_motionworks. "
621
+ f"Read-only tools are still safe."
622
+ )
623
+ return "MotionWorks IEC is not running. Editing is safe from the IDE's point of view."
624
+
625
+
626
+ @mcp.tool()
627
+ def mw_unsupported_streams(project: str | None = None) -> str:
628
+ """List streams this server cannot read, and why.
629
+
630
+ MotionWorks compresses some large POU sources into a proprietary container
631
+ (magic ``cadac758``) that is not a standard codec. Affected POUs cannot be
632
+ read or edited safely by this server. Check this before promising changes.
633
+
634
+ Args:
635
+ project: Project path, .mwt file, or bare project name.
636
+ """
637
+ try:
638
+ proj = _project(project)
639
+ except Exception as exc: # noqa: BLE001
640
+ return _fail(exc)
641
+
642
+ blocked: list[tuple[str, str]] = []
643
+ for pou in proj.pous():
644
+ name = pou.declaration_stream_name()
645
+ if name is None:
646
+ continue
647
+ try:
648
+ pou.declarations()
649
+ except UnsupportedFormat as exc:
650
+ blocked.append((pou.name, str(exc)))
651
+ except Exception as exc: # noqa: BLE001
652
+ blocked.append((pou.name, f"{type(exc).__name__}: {exc}"))
653
+
654
+ try:
655
+ proj.global_variables()
656
+ except UnsupportedFormat as exc:
657
+ blocked.append(("<global variables>", str(exc)))
658
+
659
+ if not blocked:
660
+ return f"All readable: no unsupported streams found in {proj.name}."
661
+
662
+ lines = [
663
+ f"{len(blocked)} unreadable stream group(s) in {proj.name}:",
664
+ "",
665
+ ]
666
+ for name, why in blocked:
667
+ lines.append(f"{name}")
668
+ lines.append(f" {why}")
669
+ lines.append("")
670
+ lines.append(
671
+ "Do not attempt to edit variables for these POUs. Their bodies can still "
672
+ "be inspected with mw_pou_streams."
673
+ )
674
+ return "\n".join(lines)
675
+
676
+
677
+ # ---------------------------------------------------------------------------
678
+ # Tier 1 - static analysis
679
+ # ---------------------------------------------------------------------------
680
+
681
+
682
+ def _lint_report(result: object, title: str) -> str:
683
+ """Render a LintResult as a readable report."""
684
+ errors = result.errors # type: ignore[attr-defined]
685
+ warnings = result.warnings # type: ignore[attr-defined]
686
+ findings = result.findings # type: ignore[attr-defined]
687
+
688
+ if not findings:
689
+ return f"{title}: clean (no findings)."
690
+
691
+ lines = [f"{title}: {len(errors)} error(s), {len(warnings)} warning(s)"]
692
+ for finding in findings:
693
+ lines.append("")
694
+ lines.append(finding.format())
695
+ return "\n".join(lines)
696
+
697
+
698
+ @mcp.tool()
699
+ def mw_lint_st(pou: str, project: str | None = None) -> str:
700
+ """Statically check the Structured Text body of one POU.
701
+
702
+ Catches the mistakes that matter when editing ST offline, while staying
703
+ quiet about anything it cannot resolve (library blocks, struct members,
704
+ function-block pin names). Checks:
705
+
706
+ * ``undefined-assignment-target`` (error) - assigning to a name that is
707
+ declared nowhere. This is the "invented variable" mistake.
708
+ * ``unused-local`` (warning) - a ``VAR`` local never used in the body.
709
+ * ``unassigned-fb-output`` (warning) - a function block is called but none
710
+ of its outputs is read, so the result is discarded.
711
+ * ``unterminated-statement`` (warning) - a statement missing its ``;``.
712
+
713
+ Run this after any edit and before handing the project back to rebuild.
714
+
715
+ Args:
716
+ pou: POU name.
717
+ project: Project path, .mwt file, or bare project name.
718
+ """
719
+ try:
720
+ from .stlint import lint_pou
721
+
722
+ proj = _project(project)
723
+ info = proj.pou(pou)
724
+ known = P.known_symbol_names(proj)
725
+ result = lint_pou(info, known_symbols=known)
726
+ except Exception as exc: # noqa: BLE001
727
+ return _fail(exc)
728
+ return _lint_report(result, f"Lint of {info.name} in {proj.name}")
729
+
730
+
731
+ @mcp.tool()
732
+ def mw_lint_project(project: str | None = None, errors_only: bool = False) -> str:
733
+ """Lint every Structured Text POU in a project.
734
+
735
+ Use this to find problems introduced by a set of edits, or to assess an
736
+ unfamiliar project. Graphical (LD/FBD) POUs are skipped because their logic
737
+ is not text.
738
+
739
+ Args:
740
+ project: Project path, .mwt file, or bare project name.
741
+ errors_only: Show only errors, hiding warnings.
742
+ """
743
+ try:
744
+ from .stlint import lint_pou
745
+
746
+ proj = _project(project)
747
+ known = P.known_symbol_names(proj)
748
+ blocks: list[str] = []
749
+ total_e = total_w = linted = skipped = 0
750
+ for pou in proj.pous():
751
+ if pou.language() != "ST":
752
+ skipped += 1
753
+ continue
754
+ linted += 1
755
+ result = lint_pou(pou, known_symbols=known)
756
+ total_e += len(result.errors)
757
+ total_w += len(result.warnings)
758
+ if errors_only and not result.errors:
759
+ continue
760
+ if result.findings:
761
+ blocks.append(_lint_report(result, pou.name))
762
+ except Exception as exc: # noqa: BLE001
763
+ return _fail(exc)
764
+
765
+ header = (
766
+ f"Lint of {proj.name}: {linted} ST POU(s) checked, "
767
+ f"{skipped} graphical skipped\n"
768
+ f"TOTAL: {total_e} error(s), {total_w} warning(s)"
769
+ )
770
+ if not blocks:
771
+ return header + (
772
+ "\n\nAll linted POUs are clean."
773
+ if not errors_only or total_e == 0
774
+ else "\n\nNo errors (warnings hidden)."
775
+ )
776
+ return header + "\n\n" + "\n\n".join(blocks)
777
+
778
+
779
+ @mcp.tool()
780
+ def mw_set_variable_description(
781
+ variable: str,
782
+ description: str = "",
783
+ pou: str | None = None,
784
+ project: str | None = None,
785
+ dry_run: bool = True,
786
+ ) -> str:
787
+ """Set or clear one variable's description comment.
788
+
789
+ This is the only write the server currently offers, chosen because it needs
790
+ **no binary edit at all**: MotionWorks stores descriptions only in the
791
+ textual ``.VB`` declaration stream and never in the ``.VGR`` binary grid.
792
+ (Verified: description text from real projects appears in ``.VB`` and is
793
+ absent from ``.VGR`` in both latin-1 and UTF-16.) That makes it the safest
794
+ possible mutation, and the ``.VGR`` stream is confirmed byte-identical
795
+ afterwards.
796
+
797
+ Safety behaviour, always enforced:
798
+
799
+ * refused while MotionWorks IEC is running -- the IDE holds project state in
800
+ memory and would overwrite or contradict the change;
801
+ * every file is backed up outside the project tree first;
802
+ * the result is read back and every sibling stream is verified unchanged.
803
+
804
+ Args:
805
+ variable: Exact variable name.
806
+ description: New description text. Empty string removes the description.
807
+ pou: POU name. Omit to edit the project's global variables.
808
+ project: Project path, .mwt file, or bare project name.
809
+ dry_run: Default True, which only reports what would change. Set to
810
+ False to actually write.
811
+ """
812
+ try:
813
+ from .writer import WriteRefused, apply_plan, plan_variable_description
814
+
815
+ proj = _project(project)
816
+ plan = plan_variable_description(
817
+ proj.root, pou, variable, description or None
818
+ )
819
+ except Exception as exc: # noqa: BLE001
820
+ return _fail(exc)
821
+
822
+ where = f"POU {pou}" if pou else "global variables"
823
+ if not plan.changed:
824
+ return (
825
+ f"No change needed: {variable!r} in {where} of {proj.name} already "
826
+ f"has that description state."
827
+ )
828
+
829
+ if dry_run:
830
+ return "\n".join(
831
+ [
832
+ f"DRY RUN - nothing written.",
833
+ f" project : {proj.name}",
834
+ f" target : {where}",
835
+ f" stream : {plan.stream}",
836
+ f" size : {len(plan.before)} -> {len(plan.after)} bytes",
837
+ f" variable: {variable}",
838
+ f" new text: {description!r}" if description else " new text: <removed>",
839
+ *[f" note : {n}" for n in plan.notes],
840
+ "",
841
+ "Call again with dry_run=False to apply.",
842
+ ]
843
+ )
844
+
845
+ try:
846
+ result = apply_plan(plan, proj.root)
847
+ except Exception as exc: # noqa: BLE001
848
+ return _fail(exc)
849
+
850
+ lines = [
851
+ f"WROTE description for {variable!r} in {where} of {proj.name}.",
852
+ f" stream : {result['stream']}",
853
+ f" size : {result['before_bytes']} -> {result['after_bytes']} bytes",
854
+ f" sibling streams : {result['siblings_verified']} verified unchanged",
855
+ ]
856
+ for backup in result.get("backups", []):
857
+ lines.append(f" backup : {backup}")
858
+ for note in result.get("notes", []):
859
+ lines.append(f" note : {note}")
860
+ lines.append("")
861
+ lines.append(
862
+ "NEXT: open MotionWorks IEC and run Build -> Rebuild Project, then Make. "
863
+ "The .VGR grid was not modified and does not need to be."
864
+ )
865
+ return "\n".join(lines)
866
+
867
+
868
+ @mcp.tool()
869
+ def mw_set_st_body(
870
+ pou: str,
871
+ body: str = "",
872
+ body_file: str = "",
873
+ project: str | None = None,
874
+ run_lint: bool = True,
875
+ dry_run: bool = True,
876
+ ) -> str:
877
+ """Replace a POU's Structured Text body.
878
+
879
+ Only the ``.STB`` stream changes. A body does not reference the variable
880
+ grid, so this needs no binary grid edit and no project-tree edit -- that is
881
+ why it is one of the two writes the server offers. The ``.VGR`` grid and
882
+ ``.VB`` declarations are verified byte-identical afterwards.
883
+
884
+ Provide the new body either inline with ``body`` or, for anything sizeable,
885
+ with ``body_file`` pointing at a file (real bodies reach tens of kilobytes,
886
+ which is awkward to move through a JSON argument).
887
+
888
+ Refused, always:
889
+
890
+ * while MotionWorks IEC is running;
891
+ * for graphical (LD/FBD) POUs, whose bodies are proprietary binary --
892
+ transplant a proven body instead;
893
+ * when the new body fails lint, unless ``run_lint=False`` overrides it.
894
+
895
+ Args:
896
+ pou: POU name.
897
+ body: New Structured Text, inline. Ignored if body_file is given.
898
+ body_file: Path to a file holding the new body.
899
+ project: Project path, .mwt file, or bare project name.
900
+ run_lint: Refuse the write if the new body has lint errors.
901
+ dry_run: Default True, which only reports what would change.
902
+ """
903
+ try:
904
+ from .writer import apply_st_body, plan_st_body, plan_st_body_from_file
905
+
906
+ proj = _project(project)
907
+ if body_file:
908
+ plan = plan_st_body_from_file(proj.root, pou, Path(body_file), run_lint)
909
+ else:
910
+ if not body.strip():
911
+ return (
912
+ "ERROR: no body supplied. Pass `body` inline, or `body_file` "
913
+ "with a path to the new body."
914
+ )
915
+ plan = plan_st_body(proj.root, pou, body, run_lint)
916
+ except Exception as exc: # noqa: BLE001
917
+ return _fail(exc)
918
+
919
+ if not plan.changed:
920
+ return f"No change needed: {pou} in {proj.name} already has that body."
921
+
922
+ if dry_run:
923
+ return "\n".join(
924
+ [
925
+ "DRY RUN - nothing written.",
926
+ f" project : {proj.name}",
927
+ f" POU : {pou}",
928
+ f" stream : {plan.stream}",
929
+ f" size : {len(plan.before)} -> {len(plan.after)} bytes",
930
+ *[f" note : {n}" for n in plan.notes],
931
+ "",
932
+ "Call again with dry_run=False to apply.",
933
+ ]
934
+ )
935
+
936
+ try:
937
+ result = apply_st_body(plan, proj.root)
938
+ except Exception as exc: # noqa: BLE001
939
+ return _fail(exc)
940
+
941
+ lines = [
942
+ f"WROTE Structured Text body of {pou} in {proj.name}.",
943
+ f" stream : {result['stream']}",
944
+ f" size : {result['before_bytes']} -> {result['after_bytes']} bytes",
945
+ f" sibling streams : {result['siblings_verified']} verified unchanged",
946
+ ]
947
+ for backup in result.get("backups", []):
948
+ lines.append(f" backup : {backup}")
949
+ for note in result.get("notes", []):
950
+ lines.append(f" note : {note}")
951
+ lines.append("")
952
+ lines.append(
953
+ "NEXT: open MotionWorks IEC and run Build -> Rebuild Project, then Make. "
954
+ "The .VGR grid and .VB declarations were not modified."
955
+ )
956
+ return "\n".join(lines)
957
+
958
+
959
+ @mcp.tool()
960
+ def mw_add_variable(
961
+ name: str,
962
+ type_name: str,
963
+ section: str = "VAR",
964
+ address: str = "",
965
+ initial_value: str = "",
966
+ description: str = "",
967
+ pou: str = "",
968
+ project: str | None = None,
969
+ verify: bool = False,
970
+ dry_run: bool = True,
971
+ ) -> str:
972
+ """Add a variable declaration to a POU, or to the project's globals.
973
+
974
+ Touches only the textual ``.VB`` declaration stream. Verified by compiling:
975
+ the `.VGR` binary grid does not gate compilation, so a declaration added this
976
+ way builds successfully with the grid left byte-identical.
977
+
978
+ Args:
979
+ name: New variable name.
980
+ type_name: IEC type, e.g. BOOL, INT, LREAL, MC_Power.
981
+ section: VAR (default), VAR_GLOBAL, VAR_EXTERNAL, VAR_INPUT, ...
982
+ address: Optional AT address, e.g. %MW1.100.
983
+ initial_value: Optional initial value, e.g. 0 or TRUE.
984
+ description: Optional description comment.
985
+ pou: POU name. Omit to edit the project's global variables.
986
+ project: Project path, .mwt file, or bare project name.
987
+ verify: Drive a real build afterwards and revert automatically if the
988
+ project stops compiling. Slower, needs MotionWorks.
989
+ dry_run: Default True, which only reports what would change.
990
+ """
991
+ return _declaration_write(
992
+ "add", project=project, pou=pou or None, dry_run=dry_run, verify=verify,
993
+ kwargs={
994
+ "name": name, "type_name": type_name, "section": section,
995
+ "address": address or None, "initial_value": initial_value or None,
996
+ "description": description or None,
997
+ },
998
+ )
999
+
1000
+
1001
+ @mcp.tool()
1002
+ def mw_edit_variable(
1003
+ name: str,
1004
+ new_name: str = "",
1005
+ type_name: str = "",
1006
+ description: str = "",
1007
+ address: str = "",
1008
+ initial_value: str = "",
1009
+ pou: str = "",
1010
+ project: str | None = None,
1011
+ force: bool = False,
1012
+ verify: bool = False,
1013
+ dry_run: bool = True,
1014
+ ) -> str:
1015
+ """Change a variable's type, name, address, initial value or description.
1016
+
1017
+ Only the fields you supply are changed. Verified by compiling for an
1018
+ unaddressed variable; a type change **is refused** when it contradicts an
1019
+ ``AT`` bit address, because that cannot compile (measured: retyping an
1020
+ I/O-mapped ``BOOL AT %IX...`` as ``DINT`` failed the build).
1021
+
1022
+ Args:
1023
+ name: Existing variable name.
1024
+ new_name: Rename to this. Any body still using the old name is checked.
1025
+ type_name: New type.
1026
+ description: New description. Pass "-" to clear it.
1027
+ address: New AT address.
1028
+ initial_value: New initial value.
1029
+ pou: POU name. Omit for the project's globals.
1030
+ project: Project path, .mwt file, or bare project name.
1031
+ force: Write even if a safety check objects.
1032
+ verify: Drive a real build afterwards and revert automatically if the
1033
+ project stops compiling. Slower, needs MotionWorks.
1034
+ dry_run: Default True, which only reports what would change.
1035
+ """
1036
+ kwargs: dict[str, object] = {"name": name}
1037
+ if new_name:
1038
+ kwargs["new_name"] = new_name
1039
+ if type_name:
1040
+ kwargs["type_name"] = type_name
1041
+ if address:
1042
+ kwargs["address"] = address
1043
+ if initial_value:
1044
+ kwargs["initial_value"] = initial_value
1045
+ if description == "-":
1046
+ kwargs["description"] = ""
1047
+ elif description:
1048
+ kwargs["description"] = description
1049
+ return _declaration_write(
1050
+ "edit", project=project, pou=pou or None, dry_run=dry_run,
1051
+ kwargs=kwargs, force=force, verify=verify,
1052
+ )
1053
+
1054
+
1055
+ @mcp.tool()
1056
+ def mw_delete_variable(
1057
+ name: str,
1058
+ pou: str = "",
1059
+ project: str | None = None,
1060
+ force: bool = False,
1061
+ verify: bool = False,
1062
+ dry_run: bool = True,
1063
+ ) -> str:
1064
+ """Delete a variable declaration.
1065
+
1066
+ CAUTION - this is the least reliable write the server offers. Measured
1067
+ results are mixed: deleting an unreferenced global variable compiled, but
1068
+ deleting a POU-local variable that was verified *not* referenced by its body
1069
+ still failed the build. The cause is not understood, so a delete cannot be
1070
+ predicted to succeed.
1071
+
1072
+ It is refused while any POU body still references the variable, but that
1073
+ check does not catch every failure. **Pass verify=True** to have the build
1074
+ driven and the change reverted automatically if it does not compile -- that
1075
+ is the recommended way to use this tool.
1076
+
1077
+ Args:
1078
+ name: Variable to delete.
1079
+ pou: POU name. Omit for the project's globals.
1080
+ project: Project path, .mwt file, or bare project name.
1081
+ force: Write even if the reference check objects.
1082
+ verify: Drive a real build afterwards and revert automatically if the
1083
+ project stops compiling. Strongly recommended for delete.
1084
+ dry_run: Default True, which only reports what would change.
1085
+ """
1086
+ return _declaration_write(
1087
+ "delete", project=project, pou=pou or None, dry_run=dry_run,
1088
+ kwargs={"name": name}, force=force, verify=verify,
1089
+ )
1090
+
1091
+
1092
+ def _declaration_write(
1093
+ operation: str,
1094
+ project: str | None,
1095
+ pou: str | None,
1096
+ dry_run: bool,
1097
+ kwargs: dict[str, object],
1098
+ force: bool = False,
1099
+ verify: bool = False,
1100
+ ) -> str:
1101
+ """Shared body for the variable add/edit/delete tools."""
1102
+ try:
1103
+ from . import writer
1104
+
1105
+ proj = _project(project)
1106
+ planners = {
1107
+ "add": writer.plan_variable_add,
1108
+ "edit": writer.plan_variable_edit,
1109
+ "delete": writer.plan_variable_delete,
1110
+ }
1111
+ planner = planners[operation]
1112
+ if force:
1113
+ kwargs = {**kwargs, "force": True}
1114
+ plan = planner(proj.root, pou, **kwargs)
1115
+ except Exception as exc: # noqa: BLE001
1116
+ return _fail(exc)
1117
+
1118
+ where = f"POU {pou}" if pou else "global variables"
1119
+ if not plan.changed:
1120
+ return f"No change: {operation} on {where} of {proj.name} would do nothing."
1121
+
1122
+ if dry_run:
1123
+ return "\n".join(
1124
+ [
1125
+ "DRY RUN - nothing written.",
1126
+ f" project : {proj.name}",
1127
+ f" target : {where}",
1128
+ f" stream : {plan.stream}",
1129
+ f" size : {len(plan.before)} -> {len(plan.after)} bytes",
1130
+ *[f" note : {n}" for n in plan.notes],
1131
+ "",
1132
+ "Call again with dry_run=False to apply.",
1133
+ ]
1134
+ )
1135
+
1136
+ # With verify=True, use compile-then-revert: apply, build, and restore the
1137
+ # backup automatically if the project no longer compiles.
1138
+ if verify:
1139
+ try:
1140
+ from . import build as build_module
1141
+
1142
+ outcome = build_module.apply_and_verify(plan, proj.root)
1143
+ except Exception as exc: # noqa: BLE001
1144
+ return _fail(exc)
1145
+ lines = [f"WROTE {operation} on {where} in {proj.name} (with verification)."]
1146
+ written = outcome.written
1147
+ lines.append(f" stream : {written.get('stream')}")
1148
+ lines.append(
1149
+ f" size : {written.get('before_bytes')} -> {written.get('after_bytes')} bytes"
1150
+ )
1151
+ for backup in written.get("backups", []): # type: ignore[union-attr]
1152
+ lines.append(f" backup : {backup}")
1153
+ lines.append("")
1154
+ if outcome.build is None:
1155
+ lines.append("VERIFICATION NOT RUN:")
1156
+ elif outcome.build.ok:
1157
+ lines.append(
1158
+ f"VERIFIED: the project still compiles "
1159
+ f"({outcome.build.dlls} POU DLLs produced)."
1160
+ )
1161
+ else:
1162
+ lines.append("VERIFICATION FAILED:")
1163
+ lines.append(f" build : {outcome.build.summary() if outcome.build else 'n/a'}")
1164
+ if outcome.reverted:
1165
+ lines.append(" REVERTED: the backup was restored, so the project is")
1166
+ lines.append(" unchanged. Nothing needs rebuilding.")
1167
+ elif outcome.build is not None and outcome.build.ide_would_not_load:
1168
+ lines.append(
1169
+ " The IDE did not load the project, so no compile was attempted."
1170
+ )
1171
+ lines.append(
1172
+ " That is an environment problem, not a compile failure, so the"
1173
+ )
1174
+ lines.append(" write was LEFT IN PLACE. Rebuild in MotionWorks to confirm.")
1175
+ for note in outcome.notes:
1176
+ lines.append(f" note : {note}")
1177
+ return "\n".join(lines)
1178
+
1179
+ try:
1180
+ result = writer.apply_declaration(plan, proj.root)
1181
+ except Exception as exc: # noqa: BLE001
1182
+ return _fail(exc)
1183
+
1184
+ lines = [
1185
+ f"WROTE {operation} on {where} in {proj.name}.",
1186
+ f" stream : {result['stream']}",
1187
+ f" size : {result['before_bytes']} -> {result['after_bytes']} bytes",
1188
+ f" sibling streams : {result['siblings_verified']} verified unchanged",
1189
+ ]
1190
+ for backup in result.get("backups", []):
1191
+ lines.append(f" backup : {backup}")
1192
+ for note in result.get("notes", []):
1193
+ lines.append(f" note : {note}")
1194
+ lines.append("")
1195
+ lines.append(
1196
+ "NEXT: open MotionWorks IEC and run Build -> Rebuild Project, then Make. "
1197
+ "If IsCompiled is false afterwards, restore the backup above. "
1198
+ "Pass verify=True instead to have the build driven automatically."
1199
+ )
1200
+ return "\n".join(lines)
1201
+
1202
+
1203
+ @mcp.tool()
1204
+ def mw_build_project(project: str | None = None, timeout: int = 600) -> str:
1205
+ """Build and rebuild a project through MotionWorks' own compiler.
1206
+
1207
+ Drives the IDE over COM so a change can be checked without doing it by hand.
1208
+ Requires MotionWorks to be installed and able to open the project.
1209
+
1210
+ Note it operates on the project where it lies; use it on a copy unless you
1211
+ intend to change the real project's build output.
1212
+
1213
+ Args:
1214
+ project: Project path, .mwt file, or bare project name.
1215
+ timeout: Seconds to allow for the build.
1216
+ """
1217
+ try:
1218
+ from . import build as build_module
1219
+
1220
+ available, reason = build_module.build_available()
1221
+ if not available:
1222
+ return (
1223
+ f"BUILD UNAVAILABLE: {reason}\n"
1224
+ f"Read-only tools all still work; only build verification needs "
1225
+ f"MotionWorks."
1226
+ )
1227
+ proj = _project(project)
1228
+ mwt = proj.mwt_path
1229
+ if mwt is None:
1230
+ return (
1231
+ f"ERROR: no .mwt wrapper beside {proj.root}. MotionWorks needs it "
1232
+ f"to open the project."
1233
+ )
1234
+ result = build_module.run_build(mwt, timeout=timeout)
1235
+ except Exception as exc: # noqa: BLE001
1236
+ return _fail(exc)
1237
+
1238
+ lines = [f"Build of {proj.name}: {'OK' if result.ok else 'NOT OK'}"]
1239
+ lines.append(f" {result.summary()}")
1240
+ if result.ide_would_not_load:
1241
+ lines.append("")
1242
+ lines.append(
1243
+ "The IDE never loaded the project, so no compile happened. This is an "
1244
+ "environment problem, not a code problem -- typically a startup or "
1245
+ "licensing prompt in the MotionWorks window. Open the project by hand "
1246
+ "once to clear it."
1247
+ )
1248
+ return "\n".join(lines)
1249
+
1250
+
1251
+ @mcp.tool()
1252
+ def mw_build_status() -> str:
1253
+ """Report whether the automatic build can be used, and what it needs.
1254
+
1255
+ Call this before relying on `verify=True` or `mw_build_project`, so a missing
1256
+ or blocked IDE is diagnosed up front instead of surfacing as a confusing
1257
+ failure.
1258
+ """
1259
+ try:
1260
+ from . import build as build_module
1261
+
1262
+ available, reason = build_module.build_available()
1263
+ except Exception as exc: # noqa: BLE001
1264
+ return _fail(exc)
1265
+
1266
+ lines = [
1267
+ f"Automatic build available : {'YES' if available else 'NO'}",
1268
+ f" reason : {reason}",
1269
+ f" 32-bit PowerShell : {build_module.POWERSHELL_32}",
1270
+ f" build helper : {build_module._HELPER}",
1271
+ ]
1272
+ if available:
1273
+ lines.append("")
1274
+ lines.append(
1275
+ "This means the tooling is present. It does NOT mean a build will "
1276
+ "succeed: the build drives the IDE over COM, so MotionWorks must be "
1277
+ "able to open the project. If a build reports 'project did not load', "
1278
+ "look for a startup or licensing prompt in the MotionWorks window -- "
1279
+ "that condition makes every automation call fail, including on "
1280
+ "unmodified projects."
1281
+ )
1282
+ else:
1283
+ lines.append("")
1284
+ lines.append(
1285
+ "Writes still work; they simply will not be verified by compiling, so "
1286
+ "rebuild in MotionWorks by hand afterwards."
1287
+ )
1288
+ return "\n".join(lines)
1289
+
1290
+
1291
+ @mcp.tool()
1292
+ def mw_list_tasks(project: str | None = None) -> str:
1293
+ """List the project's tasks with cycle time, priority, watchdog and programs.
1294
+
1295
+ This is context that motion code cannot be judged without: a block that needs
1296
+ longer than its task's cycle time will trip the watchdog, and the priority
1297
+ decides what it competes with. Cycle times come from the task `.SET` files and
1298
+ program assignments from the node list.
1299
+
1300
+ Args:
1301
+ project: Project path, .mwt file, or bare project name.
1302
+ """
1303
+ try:
1304
+ from .tasks import read_tasks
1305
+
1306
+ proj = _project(project)
1307
+ settings = read_tasks(proj.root)
1308
+ assignments = proj.task_assignments()
1309
+ except Exception as exc: # noqa: BLE001
1310
+ return _fail(exc)
1311
+
1312
+ if not settings:
1313
+ return (
1314
+ f"No task configuration found for {proj.name}. Expected <Task>.SET "
1315
+ f"files beside the resource src.st1."
1316
+ )
1317
+
1318
+ lines = [f"Tasks in {proj.name} ({len(settings)}), fastest first:"]
1319
+ for task in settings:
1320
+ programs = assignments.get(task.name, [])
1321
+ lines.append("")
1322
+ lines.append(f"{task.name}")
1323
+ lines.append(f" {task.summary()}")
1324
+ if programs:
1325
+ for program in programs:
1326
+ lines.append(f" runs: {program}")
1327
+ else:
1328
+ lines.append(" runs: <no program instances assigned>")
1329
+ for warning in task.warnings:
1330
+ lines.append(f" WARNING: {warning}")
1331
+
1332
+ unassigned = sorted(set(assignments) - {t.name for t in settings})
1333
+ if unassigned:
1334
+ lines.append("")
1335
+ lines.append(
1336
+ f"Note: node list mentions task(s) with no .SET file: "
1337
+ f"{', '.join(unassigned)}"
1338
+ )
1339
+ return "\n".join(lines)
1340
+
1341
+
1342
+ @mcp.tool()
1343
+ def mw_assign_task(
1344
+ program: str,
1345
+ task: str,
1346
+ project: str | None = None,
1347
+ unassign: bool = False,
1348
+ dry_run: bool = True,
1349
+ ) -> str:
1350
+ """Assign or unassign a POU's program instance to/from a task.
1351
+
1352
+ This is a structural edit to the project tree, so it rewrites `PROJECT.TRE`
1353
+ inside the root `src.st1` and records the instance in the project's
1354
+ `NODES.LST` files. MotionWorks must be closed; the write is refused otherwise.
1355
+
1356
+ Every edit is verified before it is written: the edited tree is re-parsed and
1357
+ checked (node counts, the header total, the instance's presence or absence),
1358
+ every file is backed up first, and the result is read back and re-checked.
1359
+
1360
+ Unlike the variable writes, this edit is **not compile-verified** -- the build
1361
+ path needs a working IDE, which is currently unavailable in this environment.
1362
+ Correctness here rests on the structural checks and on the byte-exact
1363
+ round-trip of the parser.
1364
+
1365
+ Args:
1366
+ program: Program instance name, normally the POU name (e.g. "StraightCut").
1367
+ task: Task name, e.g. "SlowTsk" or "FastTsk".
1368
+ project: Project path, .mwt file, or bare project name.
1369
+ unassign: Remove the assignment instead of adding it.
1370
+ dry_run: Report the plan without writing. Defaults to True.
1371
+ """
1372
+ try:
1373
+ from . import tree as tree_module
1374
+ from . import tree_writer as writer
1375
+ from .cfb import CompoundFile
1376
+
1377
+ proj = _project(project)
1378
+ src = proj.root / "src.st1"
1379
+ text = CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
1380
+ document = tree_module.parse_document(text)
1381
+ planned = (
1382
+ writer.plan_unassign(document, task, program)
1383
+ if unassign
1384
+ else writer.plan_assign(document, task, program)
1385
+ )
1386
+ except Exception as exc: # noqa: BLE001
1387
+ return _fail(exc)
1388
+
1389
+ header = [f"Project: {proj.name}", planned.summary(), ""]
1390
+ if dry_run:
1391
+ header.append("DRY RUN - nothing written.")
1392
+ header.append("Call again with dry_run=False to apply.")
1393
+ return "\n".join(header)
1394
+
1395
+ try:
1396
+ result = writer.apply_assignment(
1397
+ src,
1398
+ proj.root,
1399
+ planned,
1400
+ backup_dir=writer.default_backup_dir(proj.root),
1401
+ )
1402
+ except Exception as exc: # noqa: BLE001
1403
+ return _fail(exc)
1404
+
1405
+ header.append(f"WRITTEN. Node total is now {result['node_total']}.")
1406
+ header.append("Files: " + ", ".join(Path(f).name for f in result["files"]))
1407
+ header.append("Backups: " + ", ".join(result["backups"]))
1408
+ header.append(
1409
+ "Open MotionWorks IEC and rebuild to confirm; this edit was structurally "
1410
+ "verified but not compile-verified."
1411
+ )
1412
+ return "\n".join(header)
1413
+
1414
+
1415
+ @mcp.tool()
1416
+ def mw_plan_pou_creation(
1417
+ pou_name: str,
1418
+ template_pou: str,
1419
+ project: str | None = None,
1420
+ ) -> str:
1421
+ """Plan creating an ST POU: show the patch, and what is still unknown.
1422
+
1423
+ **This writes nothing.** It is safe to run against a real project.
1424
+
1425
+ Creating a POU adds four nodes to the project tree (the POU plus its comments,
1426
+ variables and body worksheets), clones a template POU directory, and updates
1427
+ the view list, `LIST.POU` and `PROJECT.INF`. The record layout, node ids and
1428
+ GUIDs are derived; several worksheet fields are not, because their values in
1429
+ real projects are opaque handles that differ between POUs for reasons the file
1430
+ format does not reveal.
1431
+
1432
+ Rather than guess those and risk producing a project MotionWorks cannot open,
1433
+ this reports them precisely so they can be supplied. Nothing is written until
1434
+ they are.
1435
+
1436
+ Args:
1437
+ pou_name: Name of the POU to create.
1438
+ template_pou: An existing POU in the same project to clone. The template
1439
+ decides the body kind, so clone an ST POU for an ST POU.
1440
+ project: Project path, .mwt file, or bare project name.
1441
+ """
1442
+ try:
1443
+ from .pou_writer import plan_pou_creation
1444
+
1445
+ proj = _project(project)
1446
+ plan = plan_pou_creation(proj.root, pou_name, template_pou)
1447
+ except Exception as exc: # noqa: BLE001
1448
+ return _fail(exc)
1449
+
1450
+ lines = [f"Project: {proj.name}", plan.summary()]
1451
+ unresolved = plan.unresolved()
1452
+ lines.append("")
1453
+ if unresolved:
1454
+ lines.append(
1455
+ f"NOT WRITABLE YET: {len(unresolved)} field(s) need values that cannot "
1456
+ f"be derived from the files. Nothing was written."
1457
+ )
1458
+ else:
1459
+ lines.append(
1460
+ "All fields resolved. Note that POU creation is not yet wired to a "
1461
+ "write path; this tool only plans."
1462
+ )
1463
+ return "\n".join(lines)
1464
+
1465
+
1466
+ @mcp.tool()
1467
+ def mw_create_pou(
1468
+ pou_name: str,
1469
+ template_pou: str,
1470
+ project: str | None = None,
1471
+ dry_run: bool = True,
1472
+ ) -> str:
1473
+ """Create an ST POU by cloning an existing one.
1474
+
1475
+ This is the largest structural edit the server performs. It clones the template
1476
+ POU's directory, renames the files and streams that carry the template's name,
1477
+ gives the new POU fresh worksheet GUIDs, inserts four nodes into the project
1478
+ tree, and records the POU in `LIST.POU` and `PROJECT.INF`. MotionWorks must be
1479
+ closed; the write is refused otherwise.
1480
+
1481
+ Every part of the tree edit is verified in a scratch container *before* anything
1482
+ is written, the whole POU directory is removed if any step fails, and the result
1483
+ is read back and re-checked.
1484
+
1485
+ The template decides the body kind: clone an ST POU for an ST POU. Worksheet
1486
+ state lines are copied from the template, because their values are opaque
1487
+ project-local handles that cannot be derived from the files.
1488
+
1489
+ Args:
1490
+ pou_name: Name for the new POU.
1491
+ template_pou: An existing POU in the same project to clone.
1492
+ project: Project path, .mwt file, or bare project name.
1493
+ dry_run: Report the plan without writing. Defaults to True.
1494
+ """
1495
+ try:
1496
+ from . import pou_writer as writer
1497
+
1498
+ proj = _project(project)
1499
+ plan = writer.plan_pou_creation(proj.root, pou_name, template_pou)
1500
+ except Exception as exc: # noqa: BLE001
1501
+ return _fail(exc)
1502
+
1503
+ lines = [f"Project: {proj.name}", plan.summary(), ""]
1504
+ if dry_run:
1505
+ lines.append("DRY RUN - nothing written.")
1506
+ lines.append("Call again with dry_run=False to create the POU.")
1507
+ return "\n".join(lines)
1508
+
1509
+ try:
1510
+ result = writer.apply_pou_creation(
1511
+ plan, backup_dir=writer._default_backup_dir(proj.root)
1512
+ )
1513
+ except Exception as exc: # noqa: BLE001
1514
+ return _fail(exc)
1515
+
1516
+ lines.append(
1517
+ f"CREATED {result['pou']!r} from {result['template']!r}; node total is now "
1518
+ f"{result['node_total']}."
1519
+ )
1520
+ lines.append(f"Files created: {len(result['files_created'])}")
1521
+ lines.append("Backups: " + ", ".join(result["backups"]))
1522
+ lines.append(
1523
+ "Open MotionWorks IEC and rebuild to confirm; this edit was structurally "
1524
+ "verified but not compile-verified."
1525
+ )
1526
+ return "\n".join(lines)
1527
+
1528
+
1529
+ @mcp.tool()
1530
+ def mw_delete_pou(
1531
+ pou_name: str,
1532
+ project: str | None = None,
1533
+ force: bool = False,
1534
+ dry_run: bool = True,
1535
+ ) -> str:
1536
+ """Delete a POU from the project.
1537
+
1538
+ Removes the POU's four tree nodes, its task assignment if it has one, and its
1539
+ entries in `LIST.POU`, the node lists and `PROJECT.INF`. The POU's directory is
1540
+ **moved to an archive** rather than deleted, so the operation is recoverable
1541
+ independently of the file backups. MotionWorks must be closed; the write is
1542
+ refused otherwise.
1543
+
1544
+ **The reference guard matters here.** If another POU's body calls the one being
1545
+ deleted, this is refused: the tree edit would succeed, but the project would no
1546
+ longer compile, and a dangling call is worse than a refusal. Pass `force=True`
1547
+ only if you have already removed the calls, or accept a broken build.
1548
+
1549
+ Everything is verified in a scratch container before anything is written, and if
1550
+ a later step fails the POU directory is moved back.
1551
+
1552
+ Args:
1553
+ pou_name: The POU to delete.
1554
+ project: Project path, .mwt file, or bare project name.
1555
+ force: Delete even when other POUs call it. Defaults to False.
1556
+ dry_run: Report the plan without writing. Defaults to True.
1557
+ """
1558
+ try:
1559
+ from . import pou_writer as writer
1560
+
1561
+ proj = _project(project)
1562
+ plan = writer.plan_pou_deletion(proj.root, pou_name, force=force)
1563
+ except Exception as exc: # noqa: BLE001
1564
+ return _fail(exc)
1565
+
1566
+ lines = [f"Project: {proj.name}", plan.summary(), ""]
1567
+ if dry_run:
1568
+ lines.append("DRY RUN - nothing written.")
1569
+ lines.append("Call again with dry_run=False to delete the POU.")
1570
+ return "\n".join(lines)
1571
+
1572
+ try:
1573
+ result = writer.apply_pou_deletion(
1574
+ plan, backup_dir=writer._default_backup_dir(proj.root)
1575
+ )
1576
+ except Exception as exc: # noqa: BLE001
1577
+ return _fail(exc)
1578
+
1579
+ lines.append(
1580
+ f"DELETED {pou_name!r}; node total is now {result['node_total']}."
1581
+ )
1582
+ if result.get("archived_to"):
1583
+ lines.append(f"Archived to: {result['archived_to']}")
1584
+ lines.append("Backups: " + ", ".join(result["backups"]))
1585
+ lines.append(
1586
+ "Open MotionWorks IEC and rebuild to confirm; this edit was structurally "
1587
+ "verified but not compile-verified."
1588
+ )
1589
+ return "\n".join(lines)
1590
+
1591
+
1592
+ @mcp.tool()
1593
+ def mw_transplant_ld_body(
1594
+ donor_pou: str,
1595
+ target_pou: str,
1596
+ project: str | None = None,
1597
+ dry_run: bool = True,
1598
+ ) -> str:
1599
+ """Copy a graphical (LD) body from a donor POU into another POU.
1600
+
1601
+ Authoring ladder logic from scratch is out of scope -- the body is proprietary
1602
+ binary -- so the supported route is a **proven donor**. This copies the donor's
1603
+ graphical body, its declarations and its variable grid into the target, which
1604
+ must already be a graphical POU (create one by cloning an LD POU first).
1605
+
1606
+ The donor's external declarations are **localized** on the way: `VAR_EXTERNAL`
1607
+ becomes `VAR` in the declarations, and the matching grid records change usage
1608
+ from external to local. That is necessary because the donor's external
1609
+ references belong to the donor's project, not the target's.
1610
+
1611
+ MotionWorks must be closed. Every stream is verified in a scratch container
1612
+ before anything is written, and read back afterwards.
1613
+
1614
+ Args:
1615
+ donor_pou: The POU whose body is copied. Must be graphical (LD).
1616
+ target_pou: The POU to write into. Must already be graphical.
1617
+ project: Project path, .mwt file, or bare project name.
1618
+ dry_run: Report the plan without writing. Defaults to True.
1619
+ """
1620
+ try:
1621
+ from . import pou_writer as writer
1622
+
1623
+ proj = _project(project)
1624
+ plan = writer.plan_transplant(proj.root, donor_pou, target_pou)
1625
+ except Exception as exc: # noqa: BLE001
1626
+ return _fail(exc)
1627
+
1628
+ lines = [f"Project: {proj.name}", plan.summary(), ""]
1629
+ if dry_run:
1630
+ lines.append("DRY RUN - nothing written.")
1631
+ lines.append("Call again with dry_run=False to transplant the body.")
1632
+ return "\n".join(lines)
1633
+
1634
+ try:
1635
+ result = writer.apply_transplant(
1636
+ plan, backup_dir=writer._default_backup_dir(proj.root)
1637
+ )
1638
+ except Exception as exc: # noqa: BLE001
1639
+ return _fail(exc)
1640
+
1641
+ lines.append(
1642
+ f"TRANSPLANTED {result['donor']!r} into {result['target']!r}: "
1643
+ f"{len(result['streams'])} stream(s), {result['localized']} external "
1644
+ f"declaration(s) localized."
1645
+ )
1646
+ lines.append("Backups: " + ", ".join(result["backups"]))
1647
+ lines.append(
1648
+ "Open MotionWorks IEC and rebuild to confirm; this edit was structurally "
1649
+ "verified but not compile-verified."
1650
+ )
1651
+ return "\n".join(lines)
1652
+
1653
+
1654
+ @mcp.tool()
1655
+ def mw_close_motionworks(timeout_seconds: float = 15.0) -> str:
1656
+ """Close every running MotionWorks IEC instance, then verify it is gone.
1657
+
1658
+ The IDE is asked to quit through COM first so it can flush its state, and a
1659
+ process that survives is terminated. The result is **re-checked** rather than
1660
+ assumed, so "STILL RUNNING" in the reply means do not write.
1661
+
1662
+ Any helper processes (cam editor, plot tool, simulator) are closed too, since
1663
+ they can hold the same project files open.
1664
+
1665
+ Args:
1666
+ timeout_seconds: How long to wait for processes to disappear.
1667
+ """
1668
+ try:
1669
+ from . import ide
1670
+
1671
+ result = ide.close_motionworks(timeout=timeout_seconds)
1672
+ except Exception as exc: # noqa: BLE001
1673
+ return _fail(exc)
1674
+
1675
+ lines = []
1676
+ before = result.get("before") or []
1677
+ if not before:
1678
+ lines.append("MotionWorks IEC was not running; nothing to close.")
1679
+ else:
1680
+ for pid, name in result.get("closed") or []:
1681
+ lines.append(f" closed pid {pid} ({name})")
1682
+ for pid, name in before:
1683
+ if (pid, name) not in (result.get("closed") or []):
1684
+ lines.append(f" was not running by the time it was closed: "
1685
+ f"pid {pid} ({name})")
1686
+ remaining = result.get("remaining") or []
1687
+ if remaining:
1688
+ lines.append("")
1689
+ lines.append("STILL RUNNING - do not write:")
1690
+ for pid, name in remaining:
1691
+ lines.append(f" pid {pid} {name}")
1692
+ else:
1693
+ lines.append("")
1694
+ lines.append("MotionWorks IEC is closed. Writes are safe.")
1695
+ return "\n".join(lines)
1696
+
1697
+
1698
+ @mcp.tool()
1699
+ def mw_com_status() -> str:
1700
+ """Report whether the pywin32 COM automation client can drive MotionWorks.
1701
+
1702
+ This is the Tier 3 path in Python: the `Ade.Application.550` automation server,
1703
+ which must be spoken to from a **32-bit** interpreter because `mwt.exe` is a
1704
+ 32-bit local server. Run this server with `.venv32\\Scripts\\python.exe` for COM
1705
+ work; the 64-bit interpreter is fine for everything else.
1706
+
1707
+ If the client is usable it also reports the active project's state. Note the
1708
+ distinction the reply makes: if the IDE is running but its project services are
1709
+ not answering, **no compile was attempted**, so that must not be read as "the
1710
+ project does not compile".
1711
+ """
1712
+ try:
1713
+ from . import com
1714
+
1715
+ result = com.query_via_32bit()
1716
+ except Exception as exc: # noqa: BLE001
1717
+ return _fail(exc)
1718
+
1719
+ if not result.get("ok"):
1720
+ lines = ["MotionWorks IEC automation: not available."]
1721
+ for key in ("reason", "error"):
1722
+ if result.get(key):
1723
+ lines.append(f" {key}: {result[key]}")
1724
+ lines.append("")
1725
+ lines.append(
1726
+ "The PowerShell build path (mw_build_project) works with either "
1727
+ "interpreter, because it invokes 32-bit Windows PowerShell itself."
1728
+ )
1729
+ return "\n".join(lines)
1730
+
1731
+ lines = [f"Automation client ({com.PROG_ID}): connected"]
1732
+ state = result.get("state") or {}
1733
+ for key in ("name", "project_open", "compiled", "modified"):
1734
+ lines.append(f" {key:13}: {state.get(key)}")
1735
+ if result.get("services_ready"):
1736
+ lines.append(" services : ready")
1737
+ else:
1738
+ lines.append(f" services : {result.get('services_note')}")
1739
+ lines.append("")
1740
+ lines.append(
1741
+ "The IDE is up but its project services are not answering, so NO compile "
1742
+ "was attempted. This is not a build failure."
1743
+ )
1744
+ if result.get("compile"):
1745
+ entry = result["compile"]
1746
+ lines.append("")
1747
+ lines.append(f" compile : accepted={entry.get('accepted')}")
1748
+ if entry.get("note"):
1749
+ lines.append(f" note : {entry['note']}")
1750
+ return "\n".join(lines)
1751
+
1752
+
1753
+ @mcp.tool()
1754
+ def mw_list_windows(limit: int = 25) -> str:
1755
+ """List visible windows, largest first, with their handles and positions.
1756
+
1757
+ Use this to find a window before capturing it. Handles are what the other
1758
+ screen tools take, so they stay unambiguous when two windows share a title.
1759
+ """
1760
+ try:
1761
+ from . import capture
1762
+
1763
+ return capture.describe_windows(limit=limit)
1764
+ except Exception as exc: # noqa: BLE001
1765
+ return _fail(exc)
1766
+
1767
+
1768
+ @mcp.tool()
1769
+ def mw_screenshot(
1770
+ window: str | None = None,
1771
+ handle: int | None = None,
1772
+ screen: bool = False,
1773
+ ) -> str:
1774
+ """Capture the screen, or one window, and return the PNG path.
1775
+
1776
+ This is the agent's eyes on MotionWorks. Use it to see what the IDE is actually
1777
+ showing -- a modal dialog, a compile error, a project tree -- rather than
1778
+ inferring from files. The returned path can be attached directly to see the image.
1779
+
1780
+ Args:
1781
+ window: Case-insensitive substring of the window title, e.g. "Motionworks".
1782
+ handle: A window handle from mw_list_windows; more precise than a title.
1783
+ screen: Capture every monitor instead of one window.
1784
+ """
1785
+ try:
1786
+ from . import capture
1787
+
1788
+ if screen or (window is None and handle is None):
1789
+ path = capture.capture_screen("screen")
1790
+ return f"Captured the full desktop to:\n {path}"
1791
+ path, info = capture.capture_window(
1792
+ title_contains=window, handle=handle, name="window"
1793
+ )
1794
+ return (
1795
+ f"Captured window {info['title']!r} to:\n {path}\n"
1796
+ f" size: {info['width']}x{info['height']} at "
1797
+ f"({info['left']},{info['top']}), hwnd={info['handle']}"
1798
+ )
1799
+ except Exception as exc: # noqa: BLE001
1800
+ return _fail(exc)
1801
+
1802
+
1803
+ @mcp.tool()
1804
+ def mw_read_screen_text(
1805
+ window: str | None = None,
1806
+ handle: int | None = None,
1807
+ image: str | None = None,
1808
+ ) -> str:
1809
+ """OCR text out of a window, the screen, or an existing image.
1810
+
1811
+ UI Automation is tried first and OCR is the fallback, because accessibility is not
1812
+ always available: MotionWorks' trial dialog reports its buttons as generic panes,
1813
+ and anything an application draws itself is invisible to automation entirely. When
1814
+ that happens, pixels are the only source of truth.
1815
+
1816
+ Args:
1817
+ window: Window title substring to capture and read.
1818
+ handle: A window handle from mw_list_windows.
1819
+ image: Read an already-captured PNG instead of taking a new one.
1820
+ """
1821
+ try:
1822
+ from pathlib import Path as _Path
1823
+
1824
+ from . import capture
1825
+
1826
+ if image:
1827
+ target = _Path(image)
1828
+ if not target.is_file():
1829
+ return f"ERROR: no such image: {image}"
1830
+ elif window or handle:
1831
+ target, _info = capture.capture_window(
1832
+ title_contains=window, handle=handle, name="ocr"
1833
+ )
1834
+ else:
1835
+ target = capture.capture_screen("ocr")
1836
+
1837
+ text = capture.read_text(target)
1838
+ header = f"Text read from {target}:"
1839
+ return f"{header}\n\n{text}" if text else f"{header}\n\n(no text found)"
1840
+ except Exception as exc: # noqa: BLE001
1841
+ return _fail(exc)
1842
+
1843
+
1844
+ @mcp.tool()
1845
+ def mw_ui_elements(
1846
+ window: str | None = None,
1847
+ handle: int | None = None,
1848
+ control_type: str = "",
1849
+ ) -> str:
1850
+ """List the UI Automation elements of a window (or the desktop).
1851
+
1852
+ Shows what is actually interactive, with each element's type, name and rectangle.
1853
+ Note that MotionWorks reports its dialog buttons as ``Pane`` rather than
1854
+ ``Button``, so filtering by type can hide them -- listing everything is often the
1855
+ better first move.
1856
+
1857
+ Args:
1858
+ window: Window title substring; omit to inspect the whole desktop.
1859
+ handle: A window handle from mw_list_windows.
1860
+ control_type: Filter to one type, e.g. "Button" or "Edit".
1861
+ """
1862
+ try:
1863
+ from . import uia
1864
+
1865
+ found = uia.elements(
1866
+ title_contains=window, handle=handle, control_type=control_type or ""
1867
+ )
1868
+ return uia.describe(found)
1869
+ except Exception as exc: # noqa: BLE001
1870
+ return _fail(exc)
1871
+
1872
+
1873
+ @mcp.tool()
1874
+ def mw_dismiss_trial(timeout_seconds: float = 45.0) -> str:
1875
+ """Click **Use Trial** on the MotionWorks trial dialog, if it is showing.
1876
+
1877
+ While that dialog is up the IDE never loads a project, so every COM call -- even
1878
+ on an unmodified project -- returns `Internal error`. That looks like a broken
1879
+ install but is only an unanswered prompt, so this is worth trying before
1880
+ concluding anything is wrong.
1881
+
1882
+ If this reports that it could not click, the dialog needs one manual click: this
1883
+ process has been unable to inject mouse input (synthetic clicks are accepted but
1884
+ the cursor never moves), which is recorded in docs/computer-use.md.
1885
+
1886
+ Args:
1887
+ timeout_seconds: How long to wait for the dialog to appear.
1888
+ """
1889
+ try:
1890
+ from . import uia
1891
+
1892
+ dismissed, detail = uia.dismiss_trial(timeout=timeout_seconds)
1893
+ if dismissed:
1894
+ return f"Trial dialog handled: {detail}"
1895
+ return (
1896
+ f"Trial dialog not dismissed: {detail}\n\n"
1897
+ "If the dialog is on screen, click **Use Trial** once by hand. Synthetic "
1898
+ "input does not reach the desktop from this process."
1899
+ )
1900
+ except Exception as exc: # noqa: BLE001
1901
+ return _fail(exc)
1902
+
1903
+
1904
+ def main() -> None:
1905
+ """Console entry point: run the server over stdio."""
1906
+ try:
1907
+ mcp.run("stdio")
1908
+ except KeyboardInterrupt:
1909
+ sys.exit(0)
1910
+
1911
+
1912
+ if __name__ == "__main__":
1913
+ main()