@rainbow-robotics/muscat-plugin-kit 1.10.1-dev.1 → 1.10.1

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 (205) hide show
  1. package/README.md +282 -282
  2. package/cli/README.md +274 -274
  3. package/cli/bin/create-muscat-plugin.mjs +8 -8
  4. package/cli/bin/muscat-pk.mjs +155 -155
  5. package/cli/bundle-templates.mjs +31 -31
  6. package/cli/docker/Dockerfile.cpp +28 -28
  7. package/cli/docker/Dockerfile.dev +30 -30
  8. package/cli/docker/Dockerfile.python +21 -21
  9. package/cli/m5-e2e.mjs +69 -69
  10. package/cli/prepack.mjs +10 -10
  11. package/cli/src/ai-rules.mjs +70 -70
  12. package/cli/src/build.mjs +447 -447
  13. package/cli/src/check-package.mjs +13 -13
  14. package/cli/src/completion.mjs +166 -166
  15. package/cli/src/cpp.mjs +78 -78
  16. package/cli/src/cppIde.mjs +75 -75
  17. package/cli/src/dev.mjs +165 -165
  18. package/cli/src/devBack.mjs +734 -727
  19. package/cli/src/devUi.mjs +247 -243
  20. package/cli/src/esbuild.mjs +56 -56
  21. package/cli/src/host_agent.py +223 -223
  22. package/cli/src/manifest.mjs +287 -287
  23. package/cli/src/mcp.mjs +1071 -1071
  24. package/cli/src/muscatLocal.mjs +209 -209
  25. package/cli/src/new.mjs +343 -343
  26. package/cli/src/pluginDevServer.mjs +111 -111
  27. package/cli/src/ports.mjs +193 -193
  28. package/cli/src/python.mjs +112 -112
  29. package/cli/src/ros2.mjs +109 -109
  30. package/cli/src/sdk.mjs +117 -117
  31. package/cli/src/storybook.mjs +168 -168
  32. package/cli/src/type.mjs +141 -141
  33. package/cli/src/uninstall.mjs +81 -81
  34. package/cli/src/update.mjs +295 -295
  35. package/cli/src/util.mjs +441 -441
  36. package/cli/src/zenohDev.mjs +83 -81
  37. package/cli/storybook-dist/_design-tokens.css +4957 -4957
  38. package/cli/storybook-dist/_stories-src/NxAvatar.stories.tsx +27 -27
  39. package/cli/storybook-dist/_stories-src/NxPagination.stories.tsx +28 -28
  40. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordion.stories.tsx +46 -46
  41. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordionGroup.stories.tsx +73 -73
  42. package/cli/storybook-dist/_stories-src/RBBadge/RBBadge.stories.tsx +93 -93
  43. package/cli/storybook-dist/_stories-src/RBBattery/RBBattery.stories.tsx +74 -74
  44. package/cli/storybook-dist/_stories-src/RBButton/RBCheckIconButton.stories.tsx +28 -28
  45. package/cli/storybook-dist/_stories-src/RBButton/RBCheckLabelButton.stories.tsx +84 -84
  46. package/cli/storybook-dist/_stories-src/RBButton/RBIconButton.stories.tsx +57 -57
  47. package/cli/storybook-dist/_stories-src/RBButton/RBInlineLabelButton.stories.tsx +41 -41
  48. package/cli/storybook-dist/_stories-src/RBButton/RBLabelButton.stories.tsx +60 -60
  49. package/cli/storybook-dist/_stories-src/RBDatePicker/RBDateInput.stories.tsx +44 -44
  50. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +56 -56
  51. package/cli/storybook-dist/_stories-src/RBDialog/RBAlert.stories.tsx +51 -51
  52. package/cli/storybook-dist/_stories-src/RBDialog/RBConfirm.stories.tsx +55 -55
  53. package/cli/storybook-dist/_stories-src/RBDialog/RBPrompt.stories.tsx +52 -52
  54. package/cli/storybook-dist/_stories-src/RBDivider/RBDivider.stories.tsx +93 -93
  55. package/cli/storybook-dist/_stories-src/RBEmptyView/RBEmptyView.stories.tsx +49 -49
  56. package/cli/storybook-dist/_stories-src/RBIcons/RBColorIcons.stories.tsx +108 -108
  57. package/cli/storybook-dist/_stories-src/RBIcons/RBIcons.stories.tsx +106 -106
  58. package/cli/storybook-dist/_stories-src/RBInput/RBCheckBox.stories.tsx +89 -89
  59. package/cli/storybook-dist/_stories-src/RBInput/RBMultiRangeInput.stories.tsx +64 -64
  60. package/cli/storybook-dist/_stories-src/RBInput/RBRadioBox.stories.tsx +65 -65
  61. package/cli/storybook-dist/_stories-src/RBInput/RBRadioButton.stories.tsx +68 -68
  62. package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +216 -216
  63. package/cli/storybook-dist/_stories-src/RBInput/RBSideNumberInput.stories.tsx +90 -90
  64. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextArea.stories.tsx +45 -45
  65. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextInput.stories.tsx +50 -50
  66. package/cli/storybook-dist/_stories-src/RBInput/RBTextField.stories.tsx +102 -102
  67. package/cli/storybook-dist/_stories-src/RBJoyStick/RBJoyStick.stories.tsx +31 -31
  68. package/cli/storybook-dist/_stories-src/RBLight/RBLight.stories.tsx +36 -36
  69. package/cli/storybook-dist/_stories-src/RBPagination/RBPagination.stories.tsx +49 -49
  70. package/cli/storybook-dist/_stories-src/RBShadcn/RBCard/RBCard.stories.tsx +89 -89
  71. package/cli/storybook-dist/_stories-src/RBShadcn/RBCommand/RBCommand.stories.tsx +116 -116
  72. package/cli/storybook-dist/_stories-src/RBShadcn/RBDialog/RBDialog.stories.tsx +74 -74
  73. package/cli/storybook-dist/_stories-src/RBShadcn/RBDrawer/RBDrawer.stories.tsx +99 -99
  74. package/cli/storybook-dist/_stories-src/RBShadcn/RBDropdownMenu/RBDropdownMenu.stories.tsx +123 -123
  75. package/cli/storybook-dist/_stories-src/RBShadcn/RBMenubar/RBMenubar.stories.tsx +135 -135
  76. package/cli/storybook-dist/_stories-src/RBShadcn/RBPopover/RBPopover.stories.tsx +69 -69
  77. package/cli/storybook-dist/_stories-src/RBShadcn/RBResizable/RBResizable.stories.tsx +49 -49
  78. package/cli/storybook-dist/_stories-src/RBShadcn/RBSheet/RBSheet.stories.tsx +91 -91
  79. package/cli/storybook-dist/_stories-src/RBShadcn/RBSlider/RBSlider.stories.tsx +38 -38
  80. package/cli/storybook-dist/_stories-src/RBShadcn/RBSwitch/RBSwitch.stories.tsx +112 -112
  81. package/cli/storybook-dist/_stories-src/RBShadcn/RBTabs/RBTabs.stories.tsx +75 -75
  82. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggle/RBToggle.stories.tsx +51 -51
  83. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggleGroup/RBToggleGroup.stories.tsx +84 -84
  84. package/cli/storybook-dist/_stories-src/RBTab/RBChipTab.stories.tsx +102 -102
  85. package/cli/storybook-dist/_stories-src/RBTab/RBChipsBoxTab.stories.tsx +78 -78
  86. package/cli/storybook-dist/_stories-src/RBTab/RBTab.stories.tsx +57 -57
  87. package/cli/storybook-dist/_stories-src/RBTable/RBTable.stories.tsx +177 -177
  88. package/cli/storybook-dist/_stories-src/RBTable/RBTableRow.stories.tsx +64 -64
  89. package/cli/storybook-dist/_stories-src/RBToast/RBToast.stories.tsx +117 -117
  90. package/cli/storybook-dist/_stories-src/RBTooltip/RBTooltip.stories.tsx +50 -50
  91. package/cli/storybook-dist/_stories-src/loading/RBSpinner.stories.tsx +30 -30
  92. package/cli/storybook-dist/_stories-src/nx-button/NxButton.stories.tsx +54 -54
  93. package/cli/storybook-dist/_stories-src/nx-button/NxPointerHoldButton.stories.tsx +57 -57
  94. package/cli/storybook-dist/_stories-src/nx-icons/NxIcons.stories.tsx +48 -48
  95. package/cli/storybook-dist/_stories-src/nx-inputs/NxNumberInput.stories.tsx +103 -103
  96. package/cli/storybook-dist/_stories-src/nx-inputs/NxRangeInput.stories.tsx +27 -27
  97. package/cli/storybook-dist/_stories-src/nx-inputs/NxSelectBox.stories.tsx +67 -67
  98. package/cli/storybook-dist/_stories-src/nx-inputs/NxSlideRange.stories.tsx +45 -45
  99. package/cli/storybook-dist/_stories-src/nx-inputs/NxSwitch.stories.tsx +27 -27
  100. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextArea.stories.tsx +48 -48
  101. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextInput.stories.tsx +46 -46
  102. package/cli/storybook-dist/assets/{Color-YHDXOIA2-D6JqZzDJ.js → Color-YHDXOIA2-B4IER17l.js} +1 -1
  103. package/cli/storybook-dist/assets/{Docs-ESb7JAHg.js → Docs-C8GCHFtK.js} +3 -3
  104. package/cli/storybook-dist/assets/{Docs-6gD2kUo1.js → Docs-CgdEMPQP.js} +2 -2
  105. package/cli/storybook-dist/assets/{Docs-B9wd8Ok7.js → Docs-Ck3WHvk6.js} +3 -3
  106. package/cli/storybook-dist/assets/{Docs-CgFbWWXM.js → Docs-D9NHsoeZ.js} +2 -2
  107. package/cli/storybook-dist/assets/{Docs-DBQup8Bu.js → Docs-DNYQC7nl.js} +3 -3
  108. package/cli/storybook-dist/assets/{Docs-CMjovnSY.js → Docs-DUfeiM4I.js} +4 -4
  109. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BOUI1AHi.js → DocsRenderer-CFRXHY34-C0WhyDoR.js} +1 -1
  110. package/cli/storybook-dist/assets/{NxAvatar.stories-DsYFMUh3.js → NxAvatar.stories-CwnK8AUF.js} +1 -1
  111. package/cli/storybook-dist/assets/{NxButton.stories-CHfNcqsc.js → NxButton.stories-ueYdosfQ.js} +8 -8
  112. package/cli/storybook-dist/assets/{NxNumberInput.stories-BFw_4vFE.js → NxNumberInput.stories-M5ZCLbrK.js} +1 -1
  113. package/cli/storybook-dist/assets/{NxPagination.stories-DVfxyw4M.js → NxPagination.stories-bxxUnHEL.js} +1 -1
  114. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-BzyxUWoZ.js → NxPointerHoldButton.stories-BHxB2kr5.js} +1 -1
  115. package/cli/storybook-dist/assets/{NxRangeInput.stories-DmRdzBc1.js → NxRangeInput.stories-BKfr-fp4.js} +6 -6
  116. package/cli/storybook-dist/assets/{NxSelectBox.stories-B1KkNUOf.js → NxSelectBox.stories-CG8FuTBq.js} +1 -1
  117. package/cli/storybook-dist/assets/{NxSlideRange.stories-CyOdvfQ6.js → NxSlideRange.stories-BRTFtMTL.js} +1 -1
  118. package/cli/storybook-dist/assets/{NxSwitch.stories-DrDBw0qp.js → NxSwitch.stories-Bwuyzn-R.js} +1 -1
  119. package/cli/storybook-dist/assets/{NxTextArea.stories-DTB2aqNS.js → NxTextArea.stories-COq2sGqu.js} +1 -1
  120. package/cli/storybook-dist/assets/{NxTextInput.stories-DU0rucmM.js → NxTextInput.stories-CjsBLz05.js} +1 -1
  121. package/cli/storybook-dist/assets/{RBAccordion.stories-DTfOtXtp.js → RBAccordion.stories-BARwuZ0Q.js} +3 -3
  122. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-9y9BjrUe.js → RBAccordionGroup.stories-COrPxETW.js} +10 -10
  123. package/cli/storybook-dist/assets/{RBAlert.stories-DLTAv9fT.js → RBAlert.stories-BQrkjZPt.js} +3 -3
  124. package/cli/storybook-dist/assets/{RBBadge.stories-BNMACewA.js → RBBadge.stories-BTsLL-lK.js} +1 -1
  125. package/cli/storybook-dist/assets/{RBBattery.stories-B3kXMAt5.js → RBBattery.stories-DAeQdJbc.js} +1 -1
  126. package/cli/storybook-dist/assets/{RBCard.stories-Y-s8R2n7.js → RBCard.stories-HyhOE80R.js} +39 -39
  127. package/cli/storybook-dist/assets/{RBCheckBox.stories-DBuT9tL4.js → RBCheckBox.stories-KFk0WqX_.js} +1 -1
  128. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CWu1TL56.js → RBCheckIconButton.stories-CXbbktF5.js} +1 -1
  129. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-sCu2MGCX.js → RBCheckLabelButton.stories-DWfYz_9Z.js} +17 -17
  130. package/cli/storybook-dist/assets/{RBChipTab.stories-BX9qLJkR.js → RBChipTab.stories-JPEgVFAS.js} +5 -5
  131. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-35u1aJ-P.js → RBChipsBoxTab.stories-CTPuWXrQ.js} +1 -1
  132. package/cli/storybook-dist/assets/{RBColorIcons.stories-DAOZ5Ies.js → RBColorIcons.stories-DiFRZ5jk.js} +1 -1
  133. package/cli/storybook-dist/assets/RBCommand.stories-Pf1HAVIR.js +73 -0
  134. package/cli/storybook-dist/assets/{RBConfirm.stories-iMXcUh7Z.js → RBConfirm.stories-D1cEPUXk.js} +3 -3
  135. package/cli/storybook-dist/assets/{RBDateInput.stories-hK5rXECf.js → RBDateInput.stories-RPYpCjig.js} +1 -1
  136. package/cli/storybook-dist/assets/RBDialog.stories-DJH-0XSR.js +38 -0
  137. package/cli/storybook-dist/assets/{RBDivider.stories-BAK-iZ1Z.js → RBDivider.stories-uEj1vOgK.js} +1 -1
  138. package/cli/storybook-dist/assets/{RBDrawer.stories-B2nqf2Nh.js → RBDrawer.stories-BiMzElb2.js} +12 -12
  139. package/cli/storybook-dist/assets/RBDropdownMenu.stories-Da_TBkla.js +86 -0
  140. package/cli/storybook-dist/assets/{RBEmptyView.stories-nCenrgBh.js → RBEmptyView.stories-BHeKGfPS.js} +1 -1
  141. package/cli/storybook-dist/assets/{RBIconButton.stories-DRH7O3sp.js → RBIconButton.stories-NEsRFoYp.js} +1 -1
  142. package/cli/storybook-dist/assets/{RBIcons.stories-D3PyJXG8.js → RBIcons.stories-CcZ-TTLS.js} +1 -1
  143. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-modQbt10.js → RBInlineLabelButton.stories-B0wpdQyR.js} +1 -1
  144. package/cli/storybook-dist/assets/{RBJoyStick.stories-BY14qVgR.js → RBJoyStick.stories-DEgBou3K.js} +1 -1
  145. package/cli/storybook-dist/assets/{RBLabelButton.stories-DzF7vGwm.js → RBLabelButton.stories-DT8drIsF.js} +1 -1
  146. package/cli/storybook-dist/assets/{RBLight.stories-lF4FRmVh.js → RBLight.stories-Dh-aoeyG.js} +1 -1
  147. package/cli/storybook-dist/assets/{RBMenubar.stories-XhrI7bsV.js → RBMenubar.stories-D-H7UqVh.js} +1 -1
  148. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DprllQDl.js → RBMultiRangeInput.stories-hlclv6jI.js} +1 -1
  149. package/cli/storybook-dist/assets/{RBPagination.stories-2v2gBJfC.js → RBPagination.stories-DWCeoZ_S.js} +1 -1
  150. package/cli/storybook-dist/assets/{RBPopover.stories-C8iLIZpv.js → RBPopover.stories-C514M09_.js} +1 -1
  151. package/cli/storybook-dist/assets/{RBPrompt.stories-Th_HrWHI.js → RBPrompt.stories-BSItvfiT.js} +1 -1
  152. package/cli/storybook-dist/assets/{RBRadioBox.stories-CoHuLaN3.js → RBRadioBox.stories-BBb4YbFU.js} +1 -1
  153. package/cli/storybook-dist/assets/{RBRadioButton.stories-DULxE_ff.js → RBRadioButton.stories-CXyOGJCv.js} +1 -1
  154. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D8A9KBSS.js → RBRangeDateInput.stories-qvhubu0e.js} +1 -1
  155. package/cli/storybook-dist/assets/{RBResizable.stories-C41QfrQ3.js → RBResizable.stories-CZKeyGjH.js} +14 -14
  156. package/cli/storybook-dist/assets/{RBSelectBox.stories-C1gNmwzw.js → RBSelectBox.stories-CRKP3ybf.js} +1 -1
  157. package/cli/storybook-dist/assets/RBSheet.stories-sCM3ZJUa.js +39 -0
  158. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-9LlgQ8nh.js → RBSideNumberInput.stories-Bb7gw8RF.js} +3 -3
  159. package/cli/storybook-dist/assets/{RBSlider.stories-oUgo5K9d.js → RBSlider.stories-23OAQU5z.js} +3 -3
  160. package/cli/storybook-dist/assets/{RBSpinner.stories-DDpohbY2.js → RBSpinner.stories-2xcQdcmt.js} +1 -1
  161. package/cli/storybook-dist/assets/{RBSwitch.stories-gW-GN7QO.js → RBSwitch.stories-BACUaJm_.js} +7 -7
  162. package/cli/storybook-dist/assets/{RBTab.stories-CN-PV3HH.js → RBTab.stories-CDHE3sVs.js} +1 -1
  163. package/cli/storybook-dist/assets/{RBTable.stories-BAZLpxtM.js → RBTable.stories-C0Cfhfl7.js} +29 -29
  164. package/cli/storybook-dist/assets/{RBTableRow.stories-CwjVrszB.js → RBTableRow.stories-BF8kIkpp.js} +1 -1
  165. package/cli/storybook-dist/assets/{RBTabs.stories-JuEo2sUL.js → RBTabs.stories-BdJWRq1o.js} +7 -7
  166. package/cli/storybook-dist/assets/{RBTextArea.stories-D0_Pv4qZ.js → RBTextArea.stories-CGy2xeYH.js} +3 -3
  167. package/cli/storybook-dist/assets/{RBTextField.stories-CCsOvZqV.js → RBTextField.stories-IfUOzxCM.js} +15 -15
  168. package/cli/storybook-dist/assets/{RBTextInput.stories-Cccn3VNm.js → RBTextInput.stories-VjXNFo4R.js} +3 -3
  169. package/cli/storybook-dist/assets/{RBToast.stories-I5617t0K.js → RBToast.stories-BzcobblV.js} +1 -1
  170. package/cli/storybook-dist/assets/{RBToggle.stories-DsgKjGsJ.js → RBToggle.stories-DuLE5mKv.js} +4 -4
  171. package/cli/storybook-dist/assets/{RBToggleGroup.stories-BZEzSDuz.js → RBToggleGroup.stories-CuR0N0GJ.js} +5 -5
  172. package/cli/storybook-dist/assets/{RBTooltip.stories-4oSyAWsA.js → RBTooltip.stories--aAgNC8_.js} +1 -1
  173. package/cli/storybook-dist/assets/{SideNumberInput-DSKbd0bG.js → SideNumberInput-DyWQxYUj.js} +743 -743
  174. package/cli/storybook-dist/assets/{entry-preview-B35XREGs.js → entry-preview-D7IHsza_.js} +1 -1
  175. package/cli/storybook-dist/assets/{iframe-BB797lmL.js → iframe-CbaIp5I-.js} +2 -2
  176. package/cli/storybook-dist/assets/{index-BU2567EP.js → index-C6CBgMuG.js} +3 -3
  177. package/cli/storybook-dist/assets/{preview-C7DaSWyc.js → preview-B-iYLDuW.js} +2 -2
  178. package/cli/storybook-dist/assets/{preview-CbuRHit4.js → preview-CRWqc9DH.js} +1 -1
  179. package/cli/storybook-dist/assets/{react-resizable-panels.browser-ChIvlwo7.js → react-resizable-panels.browser-8hC8dr-6.js} +8 -8
  180. package/cli/storybook-dist/iframe.html +2 -2
  181. package/cli/storybook-dist/index.html +1 -1
  182. package/cli/storybook-dist/project.json +1 -1
  183. package/cli/templates/common/.mcp.json +8 -8
  184. package/cli/templates/common/README.md +1231 -1231
  185. package/cli/templates/common/assets/icon.svg +45 -45
  186. package/cli/templates/common/manifest.json +26 -26
  187. package/cli/templates/common/package.json +22 -22
  188. package/cli/templates/common/py/main.py +125 -125
  189. package/cli/templates/common/tsconfig.json +13 -13
  190. package/cli/templates/common/ui/content.tsx +216 -216
  191. package/cli/templates/common/ui/style.css +6 -6
  192. package/cli/templates/common/vite.config.ts +10 -10
  193. package/cli/templates/cpp/cpp/main.cpp +73 -73
  194. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -25
  195. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -10
  196. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -18
  197. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -11
  198. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +23 -23
  199. package/cli/vite.d.mts +25 -25
  200. package/cli/vite.mjs +292 -292
  201. package/package.json +3 -3
  202. package/cli/storybook-dist/assets/RBCommand.stories-BSqXDkKw.js +0 -73
  203. package/cli/storybook-dist/assets/RBDialog.stories-BAktz-ku.js +0 -38
  204. package/cli/storybook-dist/assets/RBDropdownMenu.stories-DbW2fWAj.js +0 -86
  205. package/cli/storybook-dist/assets/RBSheet.stories-_6VH-RUm.js +0 -39
package/cli/src/mcp.mjs CHANGED
@@ -1,1071 +1,1071 @@
1
- // muscat-pk mcp — an MCP (Model Context Protocol) server exposing the Muscat
2
- // plugin UI/SDK surface to an AI coding agent (Claude Code, Codex, ...) so it
3
- // looks things up instead of guessing. Tools:
4
- //
5
- // Frontend UI (@rainbow-robotics/plugin-ui/react, via dist/react.d.ts):
6
- // - list_components every exported component/hook/type.
7
- // - get_component_props the real TypeScript type for one export.
8
- // - get_component_example a real Storybook story (working code, not a guess).
9
- //
10
- // Design system (rb-styles rb-token.css):
11
- // - list_design_tokens Tailwind classes to use (bg-bg-*, text-fg-*, typo-*, ...).
12
- // - search_design_tokens find a token by name or hex value.
13
- //
14
- // Plugin SDK docs + Python backend:
15
- // - search_rb_sdk search the plugin-author README (useRb(), rb.*, manifest, runtimes).
16
- // - list_rb_sdk classes/functions of the installed rainbow.rb_sdk
17
- // (lang: "python" | "cpp" — cpp reads the headers of the
18
- // rainbow-rb-sdk-dev apt package out of the cpp dev image).
19
- // - get_rb_sdk_api signature + docstring for an rb_sdk class/function.
20
- //
21
- // Add to an MCP client config, e.g. Claude Code's .mcp.json:
22
- // { "mcpServers": { "muscat": { "command": "muscat-pk", "args": ["mcp"] } } }
23
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
- import { z } from "zod";
26
- import ts from "typescript";
27
- import {
28
- appendFileSync,
29
- existsSync,
30
- mkdirSync,
31
- readdirSync,
32
- readFileSync,
33
- renameSync,
34
- statSync,
35
- writeFileSync,
36
- } from "node:fs";
37
- import { spawnSync } from "node:child_process";
38
- import { homedir } from "node:os";
39
- import { join, resolve } from "node:path";
40
- import { fileURLToPath } from "node:url";
41
- import { kitRoot, kitVersion, findPluginRoot, venvPython } from "./util.mjs";
42
- import { PYTHON_DEV_IMAGE } from "./python.mjs";
43
- import { SHIPPED_STORIES_SRC, SHIPPED_DESIGN_TOKENS } from "./storybook.mjs";
44
- import { CPP_DEV_IMAGE } from "./cpp.mjs";
45
- import { NATIVE_TO_RB, SERVER_INSTRUCTIONS } from "./ai-rules.mjs";
46
-
47
- const TEMPLATE_READMES = { python: "common/README.md", cpp: "readme-cpp/README.md" };
48
- // Design tokens: prefer the shipped copy, fall back to the monorepo source.
49
- const DESIGN_TOKENS_CSS = existsSync(SHIPPED_DESIGN_TOKENS)
50
- ? SHIPPED_DESIGN_TOKENS
51
- : resolve(kitRoot, "../..", "packages/rb-styles/css/output/rb-token.css");
52
-
53
- /* -------------------------------------------------------------------------- */
54
- /* react.d.ts introspection — component/hook list + prop types */
55
- /* -------------------------------------------------------------------------- */
56
-
57
- // plugin-ui/react's dist/react.d.ts. It's self-contained (rb-components types
58
- // are inlined at build — see plugin-ui/build-dist.mjs), so this one file is
59
- // enough even in an external install.
60
- async function reactDtsPath() {
61
- const entryUrl = await import.meta.resolve("@rainbow-robotics/plugin-ui/react");
62
- const dtsPath = fileURLToPath(entryUrl).replace(/\.js$/, ".d.ts");
63
- if (!existsSync(dtsPath)) {
64
- throw new Error(
65
- `plugin-ui type declarations not found at ${dtsPath} — reinstall or rebuild @rainbow-robotics/plugin-ui.`,
66
- );
67
- }
68
- return dtsPath;
69
- }
70
-
71
- function loadReactTypes(dtsPath) {
72
- // Without moduleResolution the `import ... from "react"` inside react.d.ts
73
- // never resolves and every React-derived type collapses to `any`.
74
- const program = ts.createProgram([dtsPath], {
75
- target: ts.ScriptTarget.ES2022,
76
- module: ts.ModuleKind.ESNext,
77
- moduleResolution: ts.ModuleResolutionKind.Bundler,
78
- skipLibCheck: true,
79
- });
80
- const sourceFile = program.getSourceFile(dtsPath);
81
- const checker = program.getTypeChecker();
82
- const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
83
- const exports = moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [];
84
- return { checker, sourceFile, exports };
85
- }
86
-
87
- // The barrel's trailing `export { A, B, ... }` produces Alias symbols whose
88
- // OWN flags don't reflect value-vs-type — resolve to the real declaration first.
89
- function resolveExport(checker, symbol) {
90
- return symbol.flags & ts.SymbolFlags.Alias
91
- ? checker.getAliasedSymbol(symbol)
92
- : symbol;
93
- }
94
-
95
- function exportKind(checker, symbol) {
96
- const resolved = resolveExport(checker, symbol);
97
- return resolved.flags & ts.SymbolFlags.Value ? "value" : "type";
98
- }
99
-
100
- // typeToString alone often prints an opaque alias (`ForwardRefExoticComponent<PropsType$s>`).
101
- // The first call signature works for plain components and forwardRef alike; its
102
- // first parameter is the props object.
103
- function ownProps(checker, type, sourceFile) {
104
- const [signature] = type.getCallSignatures?.() ?? [];
105
- const param = signature?.getParameters?.()[0];
106
- if (!param) return [];
107
- const declaration = param.valueDeclaration ?? param.declarations?.[0];
108
- if (!declaration) return [];
109
- const propsType = checker.getTypeOfSymbolAtLocation(param, declaration);
110
-
111
- return checker
112
- .getPropertiesOfType(propsType)
113
- // Without this, ~220 inherited React DOM attributes bury the component's own API.
114
- // Own = declared in react.d.ts itself (rb-components types are inlined there). Not a
115
- // "node_modules" path test: in a plugin project react.d.ts itself lives in node_modules.
116
- .filter((prop) => (prop.declarations ?? []).some((d) => d.getSourceFile() === sourceFile))
117
- .map((prop) => {
118
- const d = prop.valueDeclaration ?? prop.declarations[0];
119
- const optional = prop.flags & ts.SymbolFlags.Optional ? "?" : "";
120
- return `${prop.getName()}${optional}: ${checker.typeToString(
121
- checker.getTypeOfSymbolAtLocation(prop, d),
122
- )}`;
123
- });
124
- }
125
-
126
- // Kept out of the tool handler so every export can be precomputed and cached.
127
- function propsText(checker, sourceFile, name, symbol) {
128
- const resolved = resolveExport(checker, symbol);
129
- const declaration = resolved.valueDeclaration ?? resolved.declarations?.[0] ?? sourceFile;
130
- const type = checker.getTypeOfSymbolAtLocation(resolved, declaration);
131
- const signature = checker.typeToString(
132
- type,
133
- undefined,
134
- ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals,
135
- );
136
-
137
- const props = ownProps(checker, type, sourceFile);
138
- if (props.length) {
139
- // Keep the signature line: RBSlider's value/min/max come from Radix and the
140
- // own-props filter drops them.
141
- return `# ${name} — own props\n${props.sort().join("\n")}\n\nfull type: ${signature}`;
142
- }
143
- // No own props means either a Radix wrapper or unresolved React types. The two
144
- // look identical, so name the second case explicitly.
145
- if (/<any>/.test(signature)) {
146
- return (
147
- `# ${name} — prop types could not be resolved\n${signature}\n\n` +
148
- "React's types didn't resolve, so this collapsed to `any`. Install `@types/react` " +
149
- "in this project (`npm i -D @types/react`) and retry for the real props."
150
- );
151
- }
152
- return (
153
- `# ${name} — full type (no plugin-ui-specific props; it forwards a third-party ` +
154
- `component's props)\n${signature}`
155
- );
156
- }
157
-
158
- // Rendering every export up front is what makes the result cacheable: once the
159
- // text exists no TypeChecker is needed.
160
- function buildUiIndex(dtsPath) {
161
- const { checker, sourceFile, exports } = loadReactTypes(dtsPath);
162
- const props = {};
163
- for (const symbol of exports) {
164
- props[symbol.getName()] = propsText(checker, sourceFile, symbol.getName(), symbol);
165
- }
166
- return {
167
- components: exports
168
- .map((sym) => `${sym.getName()} (${exportKind(checker, sym)})`)
169
- .sort()
170
- .join("\n"),
171
- props,
172
- };
173
- }
174
-
175
- // ts.createProgram pulls in ~1100 source files (~1.5s) and an MCP server is a fresh
176
- // process per session. The key covers the .d.ts identity and the kit version.
177
- const UI_INDEX_CACHE_DIR = join(homedir(), ".cache", "muscat-pk");
178
- const UI_INDEX_CACHE = join(UI_INDEX_CACHE_DIR, "ui-index.json");
179
- const UI_INDEX_FORMAT = 2;
180
-
181
- let uiIndexCache = null;
182
-
183
- async function loadUiIndex() {
184
- if (uiIndexCache) return uiIndexCache;
185
- const dtsPath = await reactDtsPath();
186
- const { mtimeMs, size } = statSync(dtsPath);
187
- // Bump UI_INDEX_FORMAT when the rendered text changes, so dev builds (same version) rebuild too.
188
- const key = `${UI_INDEX_FORMAT}|${dtsPath}|${mtimeMs}|${size}|${kitVersion()}`;
189
-
190
- try {
191
- const cached = JSON.parse(readFileSync(UI_INDEX_CACHE, "utf8"));
192
- // A half-written file still parses as JSON, so check the shape too.
193
- if (
194
- cached.key === key &&
195
- typeof cached.components === "string" &&
196
- cached.props &&
197
- typeof cached.props === "object"
198
- ) {
199
- return (uiIndexCache = cached);
200
- }
201
- } catch {
202
- /* absent, unreadable or corrupt — rebuild below */
203
- }
204
-
205
- uiIndexCache = { key, ...buildUiIndex(dtsPath) };
206
- try {
207
- mkdirSync(UI_INDEX_CACHE_DIR, { recursive: true });
208
- // rename is atomic, so a concurrent server never reads a half-written file.
209
- const tmp = `${UI_INDEX_CACHE}.${process.pid}.tmp`;
210
- writeFileSync(tmp, JSON.stringify(uiIndexCache));
211
- renameSync(tmp, UI_INDEX_CACHE);
212
- } catch {
213
- /* read-only fs / sandbox: the cache is an optimisation, never a requirement */
214
- }
215
- return uiIndexCache;
216
- }
217
-
218
- /* -------------------------------------------------------------------------- */
219
- /* Storybook story source lookup */
220
- /* -------------------------------------------------------------------------- */
221
-
222
- // Case-insensitive recursive search for `${name}.stories.tsx` under the
223
- // staged story sources (see storybook.mjs's buildStorybook()).
224
- function findStorySource(name) {
225
- if (!existsSync(SHIPPED_STORIES_SRC)) return null;
226
- const target = `${name.toLowerCase()}.stories.tsx`;
227
- const stack = [SHIPPED_STORIES_SRC];
228
- while (stack.length) {
229
- const dir = stack.pop();
230
- for (const entry of readdirSync(dir)) {
231
- const full = join(dir, entry);
232
- if (statSync(full).isDirectory()) {
233
- stack.push(full);
234
- continue;
235
- }
236
- if (entry.toLowerCase() === target) return readFileSync(full, "utf8");
237
- }
238
- }
239
- return null;
240
- }
241
-
242
- // Stories are written against the monorepo (@repo/rb-components…). Point those imports at the
243
- // package a plugin actually has, and flag names plugin-ui does not re-export.
244
- function rewriteStoryImports(source, exportNames) {
245
- const exported = new Set(exportNames);
246
- const missing = new Set();
247
- const text = source.replace(
248
- /import\s+(type\s+)?\{([^}]*)\}\s*from\s*["']@repo\/rb-components[^"']*["'];?/g,
249
- (_, typeOnly = "", names) => {
250
- const list = names.split(",").map((n) => n.trim()).filter(Boolean);
251
- for (const n of list) if (!exported.has(n.replace(/^type\s+/, "").split(/\s+as\s+/)[0])) missing.add(n);
252
- return `import ${typeOnly}{ ${list.join(", ")} } from "@rainbow-robotics/plugin-ui/react";`;
253
- },
254
- );
255
- const notes = [
256
- "// Storybook story source. In a plugin, import components from \"@rainbow-robotics/plugin-ui/react\"",
257
- "// (rewritten below); @storybook/* imports, Meta/StoryObj and play() are Storybook-only.",
258
- ...(missing.size ? [`// Not exported by plugin-ui, do not use in a plugin: ${[...missing].join(", ")}`] : []),
259
- ];
260
- return `${notes.join("\n")}\n\n${text}`;
261
- }
262
-
263
- const editDistance = (a, b) => {
264
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
265
- for (let i = 1; i <= a.length; i++) {
266
- let prev = row[0]++;
267
- for (let j = 1; j <= b.length; j++) {
268
- const cur = row[j];
269
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
270
- prev = cur;
271
- }
272
- }
273
- return row[b.length];
274
- };
275
-
276
- // "RBSelect" → RBSelectBox, RBSelectOption, …; "select" (a native element) → its plugin-ui replacement.
277
- function suggestExports(name, exportNames) {
278
- const q = name.toLowerCase().replace(/[<>]/g, "");
279
- const native = NATIVE_TO_RB.filter(([el]) => el.split(/[\s/=]+/).includes(q)).map(([, rb]) => rb);
280
- const bare = q.replace(/^rb/, "");
281
- const close = exportNames
282
- .map((n) => {
283
- const lower = n.toLowerCase();
284
- const score = lower.includes(q) || (bare.length > 2 && lower.includes(bare)) ? 0 : editDistance(q, lower);
285
- return { n, score };
286
- })
287
- .filter((x) => x.score <= 3)
288
- .sort((a, b) => a.score - b.score || a.n.length - b.n.length)
289
- .slice(0, 6)
290
- .map((x) => x.n);
291
- return [...native, ...close];
292
- }
293
-
294
- function unknownExportText(name, exportNames, what) {
295
- const hints = suggestExports(name, exportNames);
296
- return (
297
- `No ${what} named "${name}" in @rainbow-robotics/plugin-ui/react.` +
298
- (hints.length ? ` Did you mean: ${hints.join(", ")}?` : "") +
299
- " Call list_components for valid names. Do not fall back to a native HTML element."
300
- );
301
- }
302
-
303
- /* -------------------------------------------------------------------------- */
304
- /* README search */
305
- /* -------------------------------------------------------------------------- */
306
-
307
- // Split the README on headings and keyword-score each section.
308
- // Headings inside fenced code blocks (```…```) don't count — a column-0
309
- // Python comment would otherwise start a bogus section.
310
- function splitSections(text) {
311
- const sections = [];
312
- let current = [];
313
- let inFence = false;
314
- for (const line of text.split("\n")) {
315
- if (/^\s*```/.test(line)) inFence = !inFence;
316
- if (!inFence && /^#{1,4} /.test(line) && current.length) {
317
- sections.push(current.join("\n"));
318
- current = [];
319
- }
320
- current.push(line);
321
- }
322
- if (current.length) sections.push(current.join("\n"));
323
- return sections;
324
- }
325
-
326
- // Standalone binaries have no templates/ on disk.
327
- async function readTemplateReadme(rel) {
328
- const onDisk = join(kitRoot, "cli/templates", rel);
329
- if (existsSync(onDisk)) return readFileSync(onDisk, "utf8");
330
- try {
331
- const { TEMPLATES } = await import("./templates-embedded.mjs");
332
- return TEMPLATES[rel] ? Buffer.from(TEMPLATES[rel], "base64").toString("utf8") : "";
333
- } catch {
334
- return "";
335
- }
336
- }
337
-
338
- async function searchReadme(query, lang) {
339
- const langs = lang ? [lang] : ["python", "cpp"];
340
- const seen = new Set();
341
- const sections = [];
342
- for (const l of langs) {
343
- for (const section of splitSections(await readTemplateReadme(TEMPLATE_READMES[l]))) {
344
- if (seen.has(section)) continue; // UI/manifest sections are shared by both READMEs
345
- seen.add(section);
346
- sections.push(langs.length > 1 && l === "cpp" ? `[C++ backend]\n${section}` : section);
347
- }
348
- }
349
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
350
-
351
- return sections
352
- .map((section) => {
353
- const lower = section.toLowerCase();
354
- const score = terms.reduce((n, term) => n + (lower.includes(term) ? 1 : 0), 0);
355
- return { section: section.trim(), score };
356
- })
357
- .filter((x) => x.score > 0)
358
- .sort((a, b) => b.score - a.score)
359
- .slice(0, 5)
360
- .map((x) => x.section);
361
- }
362
-
363
- /* -------------------------------------------------------------------------- */
364
- /* Design tokens — parsed from rb-styles' generated rb-token.css */
365
- /* -------------------------------------------------------------------------- */
366
-
367
- // A color var `--color-<name>` becomes Tailwind utilities `bg-<name>`,
368
- // `text-<name>`, `border-<name>`, `fill-<name>`. The design system's naming
369
- // already encodes intent (bg-/fg-/border-/fill-), so we surface the idiomatic
370
- // class per group.
371
- const COLOR_CLASS_HINT = {
372
- bg: (n) => `bg-${n}`, // --color-bg-default → bg-bg-default
373
- fg: (n) => `text-${n}`, // --color-fg-subtle → text-fg-subtle
374
- border: (n) => `border-${n}`,
375
- fill: (n) => `fill-${n}`,
376
- };
377
-
378
- let tokensCache = null;
379
-
380
- function loadDesignTokens() {
381
- if (tokensCache) return tokensCache;
382
- if (!existsSync(DESIGN_TOKENS_CSS)) {
383
- throw new Error(
384
- `design token CSS not found at ${DESIGN_TOKENS_CSS} — rebuild via "muscat-pk storybook --rebuild" (monorepo) or reinstall.`,
385
- );
386
- }
387
- const css = readFileSync(DESIGN_TOKENS_CSS, "utf8");
388
-
389
- // @theme { --name: value; ... } → { group, name, value, class? }
390
- const vars = [];
391
- for (const [, rawName, value] of css.matchAll(
392
- /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim,
393
- )) {
394
- const name = rawName.replace(/^--/, "");
395
- const m = name.match(/^color-(bg|fg|border|fill)-(.+)$/);
396
- const token = { name, value: value.trim() };
397
- if (m) {
398
- token.group = `color-${m[1]}`;
399
- token.class = COLOR_CLASS_HINT[m[1]](`${m[1]}-${m[2]}`);
400
- } else {
401
- token.group = name.split("-").slice(0, 2).join("-");
402
- }
403
- vars.push(token);
404
- }
405
-
406
- // @utility typo-… → composite typography classes used directly in className.
407
- const typo = [
408
- ...new Set(
409
- [...css.matchAll(/@utility\s+(typo-[a-z0-9-]+)\s*\{/g)].map((m) => m[1]),
410
- ),
411
- ].sort();
412
-
413
- tokensCache = { vars, typo };
414
- return tokensCache;
415
- }
416
-
417
- /* -------------------------------------------------------------------------- */
418
- /* rainbow-rb-sdk (Python) introspection */
419
- /* -------------------------------------------------------------------------- */
420
-
421
- function pythonCmd() {
422
- // Prefer the current plugin's own .venv — that's where `muscat-pk new` /
423
- // ensureIdeVenv() installs rainbow-rb-sdk from requirements.txt. The AI
424
- // agent's cwd is usually the plugin dir mcp was launched from.
425
- try {
426
- const py = venvPython(resolve(findPluginRoot(), ".venv"));
427
- if (existsSync(py)) return py;
428
- } catch {
429
- /* not inside a plugin project — fall through to PATH lookup */
430
- }
431
- for (const c of ["python3", "python"]) {
432
- const r = spawnSync(c, ["--version"], { encoding: "utf8" });
433
- if (r.status === 0) return c;
434
- }
435
- return null;
436
- }
437
-
438
- // Run an introspection script against the installed rainbow.rb_sdk (a plugin's
439
- // backend dependency). Returns a clear hint if it isn't importable.
440
- function introspectRbSdk(script) {
441
- const py = pythonCmd();
442
- const probe = `
443
- import json, importlib
444
- try:
445
- importlib.import_module("rainbow.rb_sdk")
446
- except Exception as e:
447
- print(json.dumps({"__notinstalled__": str(e)})); raise SystemExit(0)
448
- ${script}
449
- `;
450
- if (!py) {
451
- return introspectInDocker(probe) ?? { error: "Python not found on PATH. Install Python 3 to use rb-sdk introspection." };
452
- }
453
- const r = spawnSync(py, ["-c", probe], { encoding: "utf8", maxBuffer: 8 << 20 });
454
- if (r.status !== 0) {
455
- return { error: `python introspection failed: ${(r.stderr || "").trim().slice(0, 500)}` };
456
- }
457
- const local = parseProbe(r);
458
- return local.error && local.notInstalled ? introspectInDocker(probe) ?? local : local;
459
- }
460
-
461
- // No macOS wheels: import the plugin's pinned SDK inside the python dev image (cached).
462
- function introspectInDocker(probe) {
463
- if (spawnSync("docker", ["image", "inspect", PYTHON_DEV_IMAGE], { encoding: "utf8" }).status !== 0) return null;
464
- let ver = "";
465
- try {
466
- const req = readFileSync(join(findPluginRoot(), "requirements.txt"), "utf8");
467
- ver = req.match(/^rainbow-rb-sdk==(\S+)/m)?.[1] ?? "";
468
- } catch {
469
- /* not a python plugin — latest */
470
- }
471
- // ponytail: "latest" cache is never refreshed; delete ~/.cache/... rb-sdk-py/latest to update.
472
- const cache = join(UI_INDEX_CACHE_DIR, "rb-sdk-py", ver || "latest");
473
- mkdirSync(cache, { recursive: true });
474
- const pkg = ver ? `rainbow-rb-sdk==${ver}` : "rainbow-rb-sdk";
475
- const script =
476
- `[ -f /c/.ok ] || { pip3 install -q --break-system-packages --target /c '${pkg}' >&2 && touch /c/.ok; } && ` +
477
- `PYTHONPATH=/c python3 -c "$PROBE"`;
478
- const r = spawnSync("docker", ["run", "--rm", "-v", `${cache}:/c`, "-e", "PROBE", PYTHON_DEV_IMAGE, "bash", "-c", script], {
479
- encoding: "utf8",
480
- maxBuffer: 8 << 20,
481
- env: { ...process.env, PROBE: probe },
482
- });
483
- if (r.status !== 0) return { error: `python introspection (docker) failed: ${(r.stderr || "").trim().slice(-500)}` };
484
- return parseProbe(r);
485
- }
486
-
487
- function parseProbe(r) {
488
- // Importing rainbow.rb_sdk has side effects that print to stdout (e.g. the
489
- // Zenoh client logs its connection endpoints), ahead of our own final
490
- // print(json.dumps(...)) — so parse the last line, not the whole output.
491
- const lines = r.stdout.trim().split("\n");
492
- let data;
493
- try {
494
- data = JSON.parse(lines[lines.length - 1]);
495
- } catch {
496
- return { error: `unexpected introspection output: ${r.stdout.slice(0, 300)}` };
497
- }
498
- if (data && data.__notinstalled__) {
499
- return {
500
- notInstalled: true,
501
- error:
502
- "rainbow-rb-sdk is not installed in this environment. Install it with `pip install rainbow-rb-sdk` " +
503
- "(it's already in a plugin's requirements.txt), then retry.",
504
- };
505
- }
506
- return { data };
507
- }
508
-
509
- /* -------------------------------------------------------------------------- */
510
- /* rainbow-rb-sdk-dev (C++) headers — copied out of the cpp dev image */
511
- /* -------------------------------------------------------------------------- */
512
-
513
- const CPP_INCLUDE_CACHE = join(UI_INDEX_CACHE_DIR, "rb-sdk-include");
514
- // Headers we don't want to surface: third-party vendored code, pybind glue,
515
- // flatbuffers-generated schemas, and the industrial comm stacks.
516
- const CPP_SKIP = /^(vendor|binding|rb_comm_source)\/|_generated\.h$/;
517
-
518
- // The apt package is only installed inside the muscat-pk-cpp-dev image, so
519
- // copy /usr/include/rainbow/rb_sdk out once per image build (cache key =
520
- // image id) with `docker cp` — no host tar needed.
521
- function cppIncludeDir() {
522
- const insp = spawnSync("docker", ["image", "inspect", "-f", "{{.Id}}", CPP_DEV_IMAGE], { encoding: "utf8" });
523
- if (insp.error) return { error: "Docker not found — the C++ SDK headers live in the muscat-pk cpp dev image." };
524
- if (insp.status !== 0) {
525
- return {
526
- error:
527
- `Docker image ${CPP_DEV_IMAGE} not built yet. Run \`muscat-pk dev:back\` (or \`muscat-pk build\`) once ` +
528
- "in a cpp / rclcpp plugin to build it, then retry.",
529
- };
530
- }
531
- const dir = join(CPP_INCLUDE_CACHE, insp.stdout.trim().replace(/^sha256:/, "").slice(0, 12));
532
- const out = join(dir, "rb_sdk");
533
- if (existsSync(out)) return { dir: out };
534
- mkdirSync(dir, { recursive: true });
535
- const ctr = spawnSync("docker", ["create", CPP_DEV_IMAGE], { encoding: "utf8" });
536
- if (ctr.status !== 0) return { error: `docker create failed: ${(ctr.stderr || "").trim().slice(0, 300)}` };
537
- const id = ctr.stdout.trim();
538
- const cp = spawnSync("docker", ["cp", `${id}:/usr/include/rainbow/rb_sdk`, dir], { encoding: "utf8" });
539
- spawnSync("docker", ["rm", "-f", id], { encoding: "utf8" });
540
- if (cp.status !== 0 || !existsSync(out)) {
541
- return { error: `docker cp failed: ${(cp.stderr || "").trim().slice(0, 300)}` };
542
- }
543
- return { dir: out };
544
- }
545
-
546
- function cppHeaders(root) {
547
- const files = [];
548
- const walk = (d, rel) => {
549
- for (const name of readdirSync(d)) {
550
- const r = rel ? `${rel}/${name}` : name;
551
- if (CPP_SKIP.test(r)) continue;
552
- const full = join(d, name);
553
- if (statSync(full).isDirectory()) walk(full, r);
554
- else if (/\.hpp$/.test(name)) files.push(r);
555
- }
556
- };
557
- walk(root, "");
558
- return files.sort();
559
- }
560
-
561
- // "class Foo {" / "class Foo : public Bar {" — not forward declarations.
562
- const CLASS_RE = /^\s*(?:class|struct)\s+(?:[A-Z][A-Z0-9_]*_API\s+)?(\w+)\b(?![^\n]*;\s*$)/; // skip export macros (RB_SDK_API)
563
-
564
- function cppClassesIn(text) {
565
- return [...new Set(text.split("\n").map((l) => l.match(CLASS_RE)?.[1]).filter(Boolean))];
566
- }
567
-
568
- // Return the class body (brace-matched) plus the file's leading comment block,
569
- // which in these headers usually carries a full usage example.
570
- function cppExtract(text, className, method) {
571
- const lines = text.split("\n");
572
- const start = lines.findIndex((l) => CLASS_RE.test(l) && l.match(CLASS_RE)[1] === className);
573
- if (start < 0) return null;
574
- let depth = 0, end = start, seen = false;
575
- for (let i = start; i < lines.length; i++) {
576
- for (const ch of lines[i]) {
577
- if (ch === "{") { depth++; seen = true; }
578
- else if (ch === "}") depth--;
579
- }
580
- if (seen && depth === 0) { end = i; break; }
581
- }
582
- const body = lines.slice(start, end + 1);
583
- if (method) {
584
- // Declarations sit at class-member indent (<= 2 spaces) and the name is
585
- // not preceded by `.`/`->`/`::` — that skips call sites inside bodies.
586
- const re = new RegExp(`^ {0,2}[^(]*(?<![\\w.>:])${method.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`);
587
- const hits = [];
588
- for (let i = 0; i < body.length; i++) {
589
- if (!re.test(body[i]) || /^\s*\/\//.test(body[i])) continue;
590
- let a = i;
591
- while (a > 0 && /^\s*\/\//.test(body[a - 1])) a--;
592
- let b = i;
593
- while (b < body.length - 1 && !/[{;]/.test(body[b])) b++;
594
- hits.push(body.slice(a, b + 1).join("\n"));
595
- }
596
- return hits.length ? hits.join("\n\n") : null;
597
- }
598
- const head = [];
599
- for (const l of lines) {
600
- if (/^\s*\/\//.test(l)) head.push(l);
601
- else if (l.trim() === "" || /^#pragma/.test(l)) continue;
602
- else break;
603
- }
604
- return (head.length > 3 ? head.join("\n") + "\n\n" : "") + body.join("\n");
605
- }
606
-
607
- function cppFind(className) {
608
- const { dir, error } = cppIncludeDir();
609
- if (error) return { error };
610
- for (const rel of cppHeaders(dir)) {
611
- const text = readFileSync(join(dir, rel), "utf8");
612
- if (cppClassesIn(text).includes(className)) return { rel, text };
613
- }
614
- return { error: `No class "${className}" in the C++ SDK headers. Call list_rb_sdk with lang "cpp" for valid names.` };
615
- }
616
-
617
- /* -------------------------------------------------------------------------- */
618
- /* client registration — `muscat-pk mcp --install <client>` */
619
- /* -------------------------------------------------------------------------- */
620
-
621
- // Claude Code needs no install step: `muscat-pk new` scaffolds a project-level
622
- // .mcp.json. Codex only reads the global ~/.codex/config.toml, so we append
623
- // the server block there (idempotent).
624
- export function installMcp(target) {
625
- if (target !== "codex") {
626
- throw new Error(
627
- `Unknown --install target ${JSON.stringify(target ?? "")} — supported: "codex". ` +
628
- "(Claude Code works out of the box via the scaffolded .mcp.json.)",
629
- );
630
- }
631
- const cfgDir = join(homedir(), ".codex");
632
- const cfgPath = join(cfgDir, "config.toml");
633
- const existing = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
634
- if (/^\s*\[mcp_servers\.muscat\]/m.test(existing)) {
635
- console.log(`Codex already has the muscat MCP server registered (${cfgPath}) — nothing to do.`);
636
- return;
637
- }
638
- // ponytail: Windows Codex spawns without a shell, so it needs the .cmd shim.
639
- const command = process.platform === "win32" ? "muscat-pk.cmd" : "muscat-pk";
640
- mkdirSync(cfgDir, { recursive: true });
641
- const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
642
- appendFileSync(
643
- cfgPath,
644
- `${sep}[mcp_servers.muscat]\ncommand = "${command}"\nargs = ["mcp"]\n`,
645
- );
646
- console.log(`Registered the muscat MCP server for Codex → ${cfgPath}`);
647
- console.log("Restart Codex (or start a new session) to pick it up.");
648
- }
649
-
650
- /* -------------------------------------------------------------------------- */
651
- /* server */
652
- /* -------------------------------------------------------------------------- */
653
-
654
- export async function runMcp() {
655
- const server = new McpServer(
656
- { name: "muscat-plugin-kit", version: kitVersion() },
657
- { instructions: SERVER_INSTRUCTIONS },
658
- );
659
-
660
- server.registerTool(
661
- "list_components",
662
- {
663
- title: "List Muscat plugin UI components",
664
- description:
665
- "List every component, hook, and type exported from @rainbow-robotics/plugin-ui/react — " +
666
- "the full UI surface available to Muscat plugin authors. Call this first before guessing a " +
667
- "component name; plugin UI uses these instead of native <select>/<input>/<button> elements.",
668
- inputSchema: {},
669
- },
670
- async () => {
671
- const { components } = await loadUiIndex();
672
- return { content: [{ type: "text", text: components }] };
673
- },
674
- );
675
-
676
- server.registerTool(
677
- "get_component_props",
678
- {
679
- title: "Get a component or hook's real type signature",
680
- description:
681
- "Get the full TypeScript type for a named export of @rainbow-robotics/plugin-ui/react " +
682
- "(e.g. RBSlider, RBLabelButton, useRb, useRbFormData). Use list_components to find valid names.",
683
- inputSchema: { name: z.string().describe('Exported name, e.g. "RBSlider"') },
684
- },
685
- async ({ name }) => {
686
- const { props } = await loadUiIndex();
687
- if (!Object.hasOwn(props, name)) {
688
- return { isError: true, content: [{ type: "text", text: unknownExportText(name, Object.keys(props), "export") }] };
689
- }
690
- return { content: [{ type: "text", text: props[name] }] };
691
- },
692
- );
693
-
694
- server.registerTool(
695
- "get_component_example",
696
- {
697
- title: "Get a real Storybook usage example",
698
- description:
699
- "Get the real Storybook story source for a component (e.g. RBSlider) — actual working " +
700
- "props, imports, and layout, not a guessed shape. Prefer this over get_component_props when " +
701
- "you just need to know how to use a component, not its full type.",
702
- // `component` accepted as an alias — agents often guess it over `name`.
703
- inputSchema: {
704
- name: z.string().optional().describe('Component name, e.g. "RBSlider"'),
705
- component: z.string().optional().describe("Alias for `name`."),
706
- },
707
- },
708
- async ({ name, component }) => {
709
- const target = name ?? component;
710
- if (!target) {
711
- return {
712
- isError: true,
713
- content: [{ type: "text", text: 'Missing required argument: "name" (component name, e.g. "RBSlider").' }],
714
- };
715
- }
716
- const exportNames = Object.keys((await loadUiIndex()).props);
717
- const source = findStorySource(target);
718
- if (!source) {
719
- // An existing export without a story still has a type; an unknown name gets suggestions.
720
- const text = exportNames.includes(target)
721
- ? `No Storybook example for "${target}". Call get_component_props for its type, or run "muscat-pk storybook" to browse components live.`
722
- : unknownExportText(target, exportNames, "component");
723
- return { content: [{ type: "text", text }] };
724
- }
725
- return { content: [{ type: "text", text: rewriteStoryImports(source, exportNames) }] };
726
- },
727
- );
728
-
729
- server.registerTool(
730
- "search_rb_sdk",
731
- {
732
- title: "Search the Muscat plugin-author docs",
733
- description:
734
- "Search the Muscat plugin-author README for how to use useRb(), rb.pub/sub/action/serve, " +
735
- "plugin.router (external endpoints: router.on/subscribe/emit exposed over REST/Socket.IO/TCP/" +
736
- "MQTT/WebRTC at common/plugin/{plugin_id}/{path}, PluginRouteError, meta.transport/principal), " +
737
- "rb.toast/alert/confirm/prompt, useRbFormData/useRbSave (save/restore form data through the " +
738
- "host), manifest.json (including the \"variables\" declaration for the pyfm variable picker — " +
739
- "names require the RBP_ prefix), program variables & executor state (ctx.get_variable/" +
740
- "set_variable/executor_state/check_stop), video streaming (MediaMTX: backend publishes RTSP to " +
741
- "127.0.0.1:8554, UI plays WebRTC from :8889), ROS 2 topics (plugin namespace, rb.rosPeers to allow " +
742
- "an external PC), contribution points, and backend runtimes " +
743
- "(Python/C++/ROS2). Covers BOTH backends: the Python README (py/main.py, rainbow.rb_sdk) and the " +
744
- "C++ README (cpp/main.cpp or an rclcpp node: rb_plugin::RBPluginCall serve/subscribe/publish/" +
745
- "router(), rb_plugin::PluginProgramContext, RBManipulateSDK, CMake rb_sdk::rb_sdk). Pass lang " +
746
- "\"python\" or \"cpp\" to match the plugin's manifest runtime (cpp or ros2+rclcpp → \"cpp\"); " +
747
- "omit it to search both. IMPORTANT: " +
748
- "the backend pattern depends on the contribution point — search 'which backend pattern' before " +
749
- "writing the backend. Only a program.panel command uses PluginProgramContext; a toolbar/move.panel " +
750
- "plugin controls the robot with the SDK (RBManipulateSDK/RBAmrSDK) directly.",
751
- inputSchema: {
752
- query: z.string().describe('Keywords, e.g. "save form data" or "rb.action"'),
753
- lang: z.enum(["python", "cpp"]).optional().describe("Backend language; omit to search both"),
754
- },
755
- },
756
- async ({ query, lang }) => {
757
- const sections = await searchReadme(query, lang);
758
- if (sections.length === 0) {
759
- return { content: [{ type: "text", text: `No matching section found for "${query}".` }] };
760
- }
761
- return { content: [{ type: "text", text: sections.join("\n\n---\n\n") }] };
762
- },
763
- );
764
-
765
- /* --- design tokens --- */
766
-
767
- server.registerTool(
768
- "list_design_tokens",
769
- {
770
- title: "List Muscat design tokens (Tailwind classes)",
771
- description:
772
- "List the design system's tokens as the Tailwind classes to use in a plugin's className: " +
773
- "semantic colors (bg-bg-*, text-fg-*, border-border-*, fill-fill-*), typography (typo-*), " +
774
- "shadows, radius, spacing. Use these instead of raw hex/px so the plugin matches the host theme " +
775
- "(and light/dark). Optionally filter by category.",
776
- inputSchema: {
777
- category: z
778
- .enum(["bg", "fg", "border", "fill", "typography", "shadow", "radius", "spacing", "all"])
779
- .optional()
780
- .describe('Filter, e.g. "bg", "fg", "typography". Omit for a grouped overview.'),
781
- },
782
- },
783
- async ({ category }) => {
784
- const { vars, typo } = loadDesignTokens();
785
- const out = [];
786
-
787
- const colorGroups = { bg: [], fg: [], border: [], fill: [] };
788
- const other = { shadow: [], radius: [], spacing: [] };
789
- for (const t of vars) {
790
- const m = t.group?.match(/^color-(bg|fg|border|fill)$/);
791
- // Class name only: the value is just an indirection to another CSS var
792
- // (var(--color-scale-zinc-200)), and each class is declared twice (light/dark)
793
- // with different values — dropping the value lets the Set dedup collapse them.
794
- if (m && t.class) colorGroups[m[1]].push(t.class);
795
- else if (t.name.startsWith("shadow-")) other.shadow.push(`shadow-${t.name.slice(7)}`);
796
- else if (t.name.startsWith("radius-")) other.radius.push(`rounded-${t.name.slice(7)}`);
797
- else if (t.name.startsWith("spacing-")) other.spacing.push(t.name);
798
- }
799
-
800
- const want = (c) => !category || category === "all" || category === c;
801
- const push = (title, items) => {
802
- if (items.length) out.push(`## ${title}\n${[...new Set(items)].sort().join("\n")}`);
803
- };
804
- if (want("bg")) push("Background — bg-bg-*", colorGroups.bg);
805
- if (want("fg")) push("Text/foreground — text-fg-*", colorGroups.fg);
806
- if (want("border")) push("Border — border-border-*", colorGroups.border);
807
- if (want("fill")) push("SVG fill — fill-fill-*", colorGroups.fill);
808
- if (want("typography")) push("Typography — typo-* (use directly)", typo);
809
- if (want("shadow")) push("Shadow", other.shadow);
810
- if (want("radius")) push("Radius", other.radius);
811
- if (want("spacing")) push("Spacing scale (p-/m-/gap-/w-/h-)", other.spacing);
812
-
813
- return {
814
- content: [
815
- {
816
- type: "text",
817
- text: out.join("\n\n") || `No tokens for category "${category}".`,
818
- },
819
- ],
820
- };
821
- },
822
- );
823
-
824
- server.registerTool(
825
- "search_design_tokens",
826
- {
827
- title: "Search design tokens by name or value",
828
- description:
829
- "Find a design token by keyword — e.g. 'danger' (error colors), 'elevated', 'brand', or a hex " +
830
- "like '#fb2c36'. Returns the matching Tailwind class + value.",
831
- inputSchema: { query: z.string().describe('e.g. "danger", "elevated", "#fb2c36"') },
832
- },
833
- async ({ query }) => {
834
- const { vars, typo } = loadDesignTokens();
835
- const q = query.toLowerCase();
836
- const hits = vars
837
- .filter((t) => t.name.toLowerCase().includes(q) || t.value.toLowerCase().includes(q))
838
- .map((t) => `${t.class ?? t.name} (${t.value})`);
839
- const typoHits = typo.filter((c) => c.toLowerCase().includes(q));
840
- const all = [...typoHits, ...hits];
841
- return {
842
- content: [
843
- {
844
- type: "text",
845
- text: all.length ? [...new Set(all)].slice(0, 60).join("\n") : `No token matches "${query}".`,
846
- },
847
- ],
848
- };
849
- },
850
- );
851
-
852
- /* --- rainbow-rb-sdk (Python backend) --- */
853
-
854
- server.registerTool(
855
- "list_rb_sdk",
856
- {
857
- title: "List rainbow-rb-sdk classes and functions (Python or C++)",
858
- description:
859
- "List the public classes and functions of the plugin BACKEND SDK. lang \"python\" (default): the " +
860
- "installed rainbow.rb_sdk package (RBPluginCall, RBManipulateSDK, RBAmrSDK, PluginProgramContext, " +
861
- "...) — call before writing py/main.py; requires rainbow-rb-sdk to be pip-installed. lang \"cpp\": " +
862
- "the C++ SDK headers (apt rainbow-rb-sdk-dev: RBManipulateSDK, rb_plugin::PluginProgramContext, " +
863
- "...) grouped by header path — call before writing cpp/main.cpp or an rclcpp node; requires the " +
864
- "muscat-pk-cpp-dev Docker image (built by `muscat-pk dev:back`). RBPluginCall (UI pub/sub/serve, " +
865
- "router) exists in both: Python rainbow.rb_sdk.RBPluginCall, C++ rb_plugin::RBPluginCall " +
866
- "(plugin/rb_plugin.hpp).",
867
- inputSchema: { lang: z.enum(["python", "cpp"]).optional().describe('Default "python"') },
868
- },
869
- async ({ lang }) => {
870
- if (lang === "cpp") {
871
- const { dir, error } = cppIncludeDir();
872
- if (error) return { isError: true, content: [{ type: "text", text: error }] };
873
- const out = [];
874
- for (const rel of cppHeaders(dir)) {
875
- const classes = cppClassesIn(readFileSync(join(dir, rel), "utf8"));
876
- if (classes.length) out.push(`${rel}: ${classes.join(", ")}`);
877
- }
878
- return { content: [{ type: "text", text: `#include <rainbow/rb_sdk/...>\n\n${out.join("\n")}` }] };
879
- }
880
- const { data, error } = introspectRbSdk(`
881
- import inspect, json
882
- mod = importlib.import_module("rainbow.rb_sdk")
883
- out = []
884
- for name in sorted(getattr(mod, "__all__", None) or dir(mod)):
885
- if name.startswith("_"): continue
886
- obj = getattr(mod, name)
887
- if inspect.isclass(obj): out.append(name + " (class)")
888
- elif inspect.isfunction(obj) or inspect.isbuiltin(obj): out.append(name + " (function)")
889
- print(json.dumps(out))
890
- `);
891
- if (error) return { isError: true, content: [{ type: "text", text: error }] };
892
- return { content: [{ type: "text", text: data.join("\n") }] };
893
- },
894
- );
895
-
896
- server.registerTool(
897
- "get_rb_sdk_api",
898
- {
899
- title: "Get a rainbow-rb-sdk class/function signature + docs (Python or C++)",
900
- description:
901
- "Get the signature(s) and docstring for a rainbow.rb_sdk class or function (e.g. " +
902
- "'PluginProgramContext', 'RBManipulateSDK'). For a class, lists its public methods with " +
903
- "signatures. To drill into ONE method of a class (e.g. RBManipulateSDK.call_move_j), pass " +
904
- "`method` alongside `name` — do not put both in `name`. lang \"python\" (default) introspects the " +
905
- "pip-installed package. lang \"cpp\" returns the class declaration from the C++ SDK header " +
906
- "(rainbow-rb-sdk-dev) with its header comment — usually a full usage example — and the " +
907
- "#include path; with `method`, just that method's declaration and comment.",
908
- inputSchema: {
909
- name: z.string().describe('Class or function name, e.g. "RBManipulateSDK"'),
910
- method: z.string().optional().describe('Optional: a specific method on that class, e.g. "call_move_j"'),
911
- lang: z.enum(["python", "cpp"]).optional().describe('Default "python"'),
912
- },
913
- },
914
- async ({ name, method, lang }) => {
915
- // Tolerate a combined "ClassName method_name" in `name` (a common agent
916
- // mistake) by splitting it ourselves instead of failing validation.
917
- let className = name;
918
- let methodName = method;
919
- if (!methodName && /\s/.test(name.trim())) {
920
- const parts = name.trim().split(/\s+/);
921
- if (parts.length === 2) [className, methodName] = parts;
922
- }
923
-
924
- if (lang === "cpp") {
925
- const cls = className.replace(/^.*::/, "");
926
- let { rel, text, error } = cppFind(cls);
927
- if (error) return { isError: true, content: [{ type: "text", text: error }] };
928
- let body = cppExtract(text, cls, methodName);
929
- let owner = cls;
930
- if (!body && methodName) {
931
- // Like the Python path: most calls live on sub-SDK members
932
- // (RBManipulateMoveSDK move; -> sdk.move.call_move_j) or on the base
933
- // class — look there before giving up.
934
- const decl = cppExtract(text, cls);
935
- const cands = [
936
- ...[...decl.matchAll(/^\s*(\w+SDK)\s+(\w+);/gm)].map((m) => [m[1], `${cls}.${m[2]}`]),
937
- ...[...decl.matchAll(/:\s*public\s+(\w+)/g)].map((m) => [m[1], `${cls} (base ${m[1]})`]),
938
- ];
939
- for (const [c, label] of cands) {
940
- const f = cppFind(c);
941
- if (f.error) continue;
942
- const b = cppExtract(f.text, c, methodName);
943
- if (b) { ({ rel, text } = f); body = b; owner = label; break; }
944
- }
945
- }
946
- if (!body) {
947
- return {
948
- isError: true,
949
- content: [{ type: "text", text: `No method "${methodName}" declared in class "${cls}" (${rel}) or its sub-SDK members / base class. Call get_rb_sdk_api with just \`name\` to see the class.` }],
950
- };
951
- }
952
- const ns = text.match(/^namespace\s+(\w+)\s*\{/m)?.[1];
953
- const header =
954
- `// #include <rainbow/rb_sdk/${rel}>${ns ? ` (namespace ${ns})` : ""}\n` +
955
- `// link: find_package(rb_sdk) + rb_sdk::rb_sdk\n` +
956
- (methodName ? `// ${owner}.${methodName}\n` : "") + "\n";
957
- return { content: [{ type: "text", text: header + body.slice(0, 60_000) }] };
958
- }
959
-
960
- const { data, error } = introspectRbSdk(`
961
- import inspect, json
962
- mod = importlib.import_module("rainbow.rb_sdk")
963
- name = ${JSON.stringify(className)}
964
- method = ${methodName ? JSON.stringify(methodName) : "None"}
965
- obj = getattr(mod, name, None)
966
- if obj is None:
967
- print(json.dumps({"__missing__": True})); raise SystemExit(0)
968
- def sig(o):
969
- try: return str(inspect.signature(o))
970
- except Exception: return "(...)"
971
-
972
- if method:
973
- # Direct method on the class itself.
974
- fn = getattr(obj, method, None) if inspect.isclass(obj) else None
975
- # rb_sdk classes often group calls under a nested namespace set in
976
- # __init__ (e.g. RBManipulateSDK().move.call_move_j) — that only exists on
977
- # an INSTANCE, which we won't construct (side effects, needs a robot
978
- # connection). Best-effort: search the class's own __init__ source text
979
- # for self.<attr> = <NestedClass>(...) and check each nested class too.
980
- owner = name
981
- if fn is None and inspect.isclass(obj):
982
- try:
983
- src = inspect.getsource(obj.__init__)
984
- except Exception:
985
- src = ""
986
- import re, sys as _sys
987
- # The nested sub-SDK classes (e.g. RBManipulateMoveSDK) are imported in
988
- # the module that DEFINES obj (rainbow.rb_sdk.manipulate), not in the
989
- # rainbow.rb_sdk top-level namespace — resolve names against both.
990
- _defmod = _sys.modules.get(getattr(obj, "__module__", ""))
991
- for attr, nested_name in re.findall(r"self\\.(\\w+)\\s*=\\s*(\\w+)\\(", src):
992
- nested = (
993
- (getattr(_defmod, nested_name, None) if _defmod else None)
994
- or mod.__dict__.get(nested_name)
995
- or globals().get(nested_name)
996
- )
997
- cand = getattr(nested, method, None) if nested else None
998
- if cand:
999
- fn = cand
1000
- owner = f"{name}.{attr} ({nested_name})"
1001
- break
1002
- if fn is None:
1003
- print(json.dumps({"__missing__": True, "__method__": True})); raise SystemExit(0)
1004
- print(json.dumps({
1005
- "name": name, "kind": "method", "owner": owner,
1006
- "signature": method + sig(fn), "doc": inspect.getdoc(fn) or "",
1007
- }))
1008
- raise SystemExit(0)
1009
-
1010
- res = {"name": name, "doc": inspect.getdoc(obj) or ""}
1011
- if inspect.isclass(obj):
1012
- res["kind"] = "class"
1013
- res["init"] = name + sig(obj.__init__ if "__init__" in dir(obj) else obj)
1014
- def _methods_of(cls, prefix=""):
1015
- out = []
1016
- for m, fn in inspect.getmembers(cls, predicate=inspect.isfunction):
1017
- if m.startswith("_"): continue
1018
- out.append({"name": prefix + m, "sig": prefix + m + sig(fn),
1019
- "doc": (inspect.getdoc(fn) or "").split("\\n")[0]})
1020
- return out
1021
- methods = _methods_of(obj)
1022
- # Most rb_sdk calls live on nested sub-SDKs assigned in __init__
1023
- # (e.g. self.move = RBManipulateMoveSDK() -> obj.move.call_move_j). List
1024
- # those too, name-prefixed, so the API is discoverable without an instance.
1025
- import re as _re, sys as _sys
1026
- _defmod = _sys.modules.get(getattr(obj, "__module__", ""))
1027
- try:
1028
- _src = inspect.getsource(obj.__init__)
1029
- except Exception:
1030
- _src = ""
1031
- for _attr, _nested_name in _re.findall(r"self\\.(\\w+)\\s*=\\s*(\\w+)\\(", _src):
1032
- _nested = (getattr(_defmod, _nested_name, None) if _defmod else None) or mod.__dict__.get(_nested_name)
1033
- if inspect.isclass(_nested):
1034
- methods.extend(_methods_of(_nested, prefix=_attr + "."))
1035
- res["methods"] = methods
1036
- else:
1037
- res["kind"] = "function"
1038
- res["signature"] = name + sig(obj)
1039
- print(json.dumps(res))
1040
- `);
1041
- if (error) return { isError: true, content: [{ type: "text", text: error }] };
1042
- if (data.__missing__) {
1043
- const text = data.__method__
1044
- ? `No method "${methodName}" found directly on "${className}" (nor in an obvious nested ` +
1045
- `namespace set in __init__). Call get_rb_sdk_api with just \`name: "${className}"\` to see ` +
1046
- `its full method list and constructor first.`
1047
- : `No "${className}" in rainbow.rb_sdk. Call list_rb_sdk for valid names.`;
1048
- return { isError: true, content: [{ type: "text", text }] };
1049
- }
1050
- if (data.kind === "method") {
1051
- const lines = [`# ${data.owner}.${data.signature.split("(")[0]}`, data.signature];
1052
- if (data.doc) lines.push("", data.doc);
1053
- return { content: [{ type: "text", text: lines.join("\n") }] };
1054
- }
1055
- const lines = [`# ${data.name} (${data.kind})`];
1056
- if (data.doc) lines.push(data.doc, "");
1057
- if (data.kind === "class") {
1058
- lines.push(`constructor: ${data.init}`, "", "methods:");
1059
- for (const m of data.methods) {
1060
- lines.push(` ${m.sig}${m.doc ? ` — ${m.doc}` : ""}`);
1061
- }
1062
- } else {
1063
- lines.push(data.signature);
1064
- }
1065
- return { content: [{ type: "text", text: lines.join("\n") }] };
1066
- },
1067
- );
1068
-
1069
- const transport = new StdioServerTransport();
1070
- await server.connect(transport);
1071
- }
1
+ // muscat-pk mcp — an MCP (Model Context Protocol) server exposing the Muscat
2
+ // plugin UI/SDK surface to an AI coding agent (Claude Code, Codex, ...) so it
3
+ // looks things up instead of guessing. Tools:
4
+ //
5
+ // Frontend UI (@rainbow-robotics/plugin-ui/react, via dist/react.d.ts):
6
+ // - list_components every exported component/hook/type.
7
+ // - get_component_props the real TypeScript type for one export.
8
+ // - get_component_example a real Storybook story (working code, not a guess).
9
+ //
10
+ // Design system (rb-styles rb-token.css):
11
+ // - list_design_tokens Tailwind classes to use (bg-bg-*, text-fg-*, typo-*, ...).
12
+ // - search_design_tokens find a token by name or hex value.
13
+ //
14
+ // Plugin SDK docs + Python backend:
15
+ // - search_rb_sdk search the plugin-author README (useRb(), rb.*, manifest, runtimes).
16
+ // - list_rb_sdk classes/functions of the installed rainbow.rb_sdk
17
+ // (lang: "python" | "cpp" — cpp reads the headers of the
18
+ // rainbow-rb-sdk-dev apt package out of the cpp dev image).
19
+ // - get_rb_sdk_api signature + docstring for an rb_sdk class/function.
20
+ //
21
+ // Add to an MCP client config, e.g. Claude Code's .mcp.json:
22
+ // { "mcpServers": { "muscat": { "command": "muscat-pk", "args": ["mcp"] } } }
23
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { z } from "zod";
26
+ import ts from "typescript";
27
+ import {
28
+ appendFileSync,
29
+ existsSync,
30
+ mkdirSync,
31
+ readdirSync,
32
+ readFileSync,
33
+ renameSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { spawnSync } from "node:child_process";
38
+ import { homedir } from "node:os";
39
+ import { join, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { kitRoot, kitVersion, findPluginRoot, venvPython } from "./util.mjs";
42
+ import { PYTHON_DEV_IMAGE } from "./python.mjs";
43
+ import { SHIPPED_STORIES_SRC, SHIPPED_DESIGN_TOKENS } from "./storybook.mjs";
44
+ import { CPP_DEV_IMAGE } from "./cpp.mjs";
45
+ import { NATIVE_TO_RB, SERVER_INSTRUCTIONS } from "./ai-rules.mjs";
46
+
47
+ const TEMPLATE_READMES = { python: "common/README.md", cpp: "readme-cpp/README.md" };
48
+ // Design tokens: prefer the shipped copy, fall back to the monorepo source.
49
+ const DESIGN_TOKENS_CSS = existsSync(SHIPPED_DESIGN_TOKENS)
50
+ ? SHIPPED_DESIGN_TOKENS
51
+ : resolve(kitRoot, "../..", "packages/rb-styles/css/output/rb-token.css");
52
+
53
+ /* -------------------------------------------------------------------------- */
54
+ /* react.d.ts introspection — component/hook list + prop types */
55
+ /* -------------------------------------------------------------------------- */
56
+
57
+ // plugin-ui/react's dist/react.d.ts. It's self-contained (rb-components types
58
+ // are inlined at build — see plugin-ui/build-dist.mjs), so this one file is
59
+ // enough even in an external install.
60
+ async function reactDtsPath() {
61
+ const entryUrl = await import.meta.resolve("@rainbow-robotics/plugin-ui/react");
62
+ const dtsPath = fileURLToPath(entryUrl).replace(/\.js$/, ".d.ts");
63
+ if (!existsSync(dtsPath)) {
64
+ throw new Error(
65
+ `plugin-ui type declarations not found at ${dtsPath} — reinstall or rebuild @rainbow-robotics/plugin-ui.`,
66
+ );
67
+ }
68
+ return dtsPath;
69
+ }
70
+
71
+ function loadReactTypes(dtsPath) {
72
+ // Without moduleResolution the `import ... from "react"` inside react.d.ts
73
+ // never resolves and every React-derived type collapses to `any`.
74
+ const program = ts.createProgram([dtsPath], {
75
+ target: ts.ScriptTarget.ES2022,
76
+ module: ts.ModuleKind.ESNext,
77
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
78
+ skipLibCheck: true,
79
+ });
80
+ const sourceFile = program.getSourceFile(dtsPath);
81
+ const checker = program.getTypeChecker();
82
+ const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
83
+ const exports = moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [];
84
+ return { checker, sourceFile, exports };
85
+ }
86
+
87
+ // The barrel's trailing `export { A, B, ... }` produces Alias symbols whose
88
+ // OWN flags don't reflect value-vs-type — resolve to the real declaration first.
89
+ function resolveExport(checker, symbol) {
90
+ return symbol.flags & ts.SymbolFlags.Alias
91
+ ? checker.getAliasedSymbol(symbol)
92
+ : symbol;
93
+ }
94
+
95
+ function exportKind(checker, symbol) {
96
+ const resolved = resolveExport(checker, symbol);
97
+ return resolved.flags & ts.SymbolFlags.Value ? "value" : "type";
98
+ }
99
+
100
+ // typeToString alone often prints an opaque alias (`ForwardRefExoticComponent<PropsType$s>`).
101
+ // The first call signature works for plain components and forwardRef alike; its
102
+ // first parameter is the props object.
103
+ function ownProps(checker, type, sourceFile) {
104
+ const [signature] = type.getCallSignatures?.() ?? [];
105
+ const param = signature?.getParameters?.()[0];
106
+ if (!param) return [];
107
+ const declaration = param.valueDeclaration ?? param.declarations?.[0];
108
+ if (!declaration) return [];
109
+ const propsType = checker.getTypeOfSymbolAtLocation(param, declaration);
110
+
111
+ return checker
112
+ .getPropertiesOfType(propsType)
113
+ // Without this, ~220 inherited React DOM attributes bury the component's own API.
114
+ // Own = declared in react.d.ts itself (rb-components types are inlined there). Not a
115
+ // "node_modules" path test: in a plugin project react.d.ts itself lives in node_modules.
116
+ .filter((prop) => (prop.declarations ?? []).some((d) => d.getSourceFile() === sourceFile))
117
+ .map((prop) => {
118
+ const d = prop.valueDeclaration ?? prop.declarations[0];
119
+ const optional = prop.flags & ts.SymbolFlags.Optional ? "?" : "";
120
+ return `${prop.getName()}${optional}: ${checker.typeToString(
121
+ checker.getTypeOfSymbolAtLocation(prop, d),
122
+ )}`;
123
+ });
124
+ }
125
+
126
+ // Kept out of the tool handler so every export can be precomputed and cached.
127
+ function propsText(checker, sourceFile, name, symbol) {
128
+ const resolved = resolveExport(checker, symbol);
129
+ const declaration = resolved.valueDeclaration ?? resolved.declarations?.[0] ?? sourceFile;
130
+ const type = checker.getTypeOfSymbolAtLocation(resolved, declaration);
131
+ const signature = checker.typeToString(
132
+ type,
133
+ undefined,
134
+ ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals,
135
+ );
136
+
137
+ const props = ownProps(checker, type, sourceFile);
138
+ if (props.length) {
139
+ // Keep the signature line: RBSlider's value/min/max come from Radix and the
140
+ // own-props filter drops them.
141
+ return `# ${name} — own props\n${props.sort().join("\n")}\n\nfull type: ${signature}`;
142
+ }
143
+ // No own props means either a Radix wrapper or unresolved React types. The two
144
+ // look identical, so name the second case explicitly.
145
+ if (/<any>/.test(signature)) {
146
+ return (
147
+ `# ${name} — prop types could not be resolved\n${signature}\n\n` +
148
+ "React's types didn't resolve, so this collapsed to `any`. Install `@types/react` " +
149
+ "in this project (`npm i -D @types/react`) and retry for the real props."
150
+ );
151
+ }
152
+ return (
153
+ `# ${name} — full type (no plugin-ui-specific props; it forwards a third-party ` +
154
+ `component's props)\n${signature}`
155
+ );
156
+ }
157
+
158
+ // Rendering every export up front is what makes the result cacheable: once the
159
+ // text exists no TypeChecker is needed.
160
+ function buildUiIndex(dtsPath) {
161
+ const { checker, sourceFile, exports } = loadReactTypes(dtsPath);
162
+ const props = {};
163
+ for (const symbol of exports) {
164
+ props[symbol.getName()] = propsText(checker, sourceFile, symbol.getName(), symbol);
165
+ }
166
+ return {
167
+ components: exports
168
+ .map((sym) => `${sym.getName()} (${exportKind(checker, sym)})`)
169
+ .sort()
170
+ .join("\n"),
171
+ props,
172
+ };
173
+ }
174
+
175
+ // ts.createProgram pulls in ~1100 source files (~1.5s) and an MCP server is a fresh
176
+ // process per session. The key covers the .d.ts identity and the kit version.
177
+ const UI_INDEX_CACHE_DIR = join(homedir(), ".cache", "muscat-pk");
178
+ const UI_INDEX_CACHE = join(UI_INDEX_CACHE_DIR, "ui-index.json");
179
+ const UI_INDEX_FORMAT = 2;
180
+
181
+ let uiIndexCache = null;
182
+
183
+ async function loadUiIndex() {
184
+ if (uiIndexCache) return uiIndexCache;
185
+ const dtsPath = await reactDtsPath();
186
+ const { mtimeMs, size } = statSync(dtsPath);
187
+ // Bump UI_INDEX_FORMAT when the rendered text changes, so dev builds (same version) rebuild too.
188
+ const key = `${UI_INDEX_FORMAT}|${dtsPath}|${mtimeMs}|${size}|${kitVersion()}`;
189
+
190
+ try {
191
+ const cached = JSON.parse(readFileSync(UI_INDEX_CACHE, "utf8"));
192
+ // A half-written file still parses as JSON, so check the shape too.
193
+ if (
194
+ cached.key === key &&
195
+ typeof cached.components === "string" &&
196
+ cached.props &&
197
+ typeof cached.props === "object"
198
+ ) {
199
+ return (uiIndexCache = cached);
200
+ }
201
+ } catch {
202
+ /* absent, unreadable or corrupt — rebuild below */
203
+ }
204
+
205
+ uiIndexCache = { key, ...buildUiIndex(dtsPath) };
206
+ try {
207
+ mkdirSync(UI_INDEX_CACHE_DIR, { recursive: true });
208
+ // rename is atomic, so a concurrent server never reads a half-written file.
209
+ const tmp = `${UI_INDEX_CACHE}.${process.pid}.tmp`;
210
+ writeFileSync(tmp, JSON.stringify(uiIndexCache));
211
+ renameSync(tmp, UI_INDEX_CACHE);
212
+ } catch {
213
+ /* read-only fs / sandbox: the cache is an optimisation, never a requirement */
214
+ }
215
+ return uiIndexCache;
216
+ }
217
+
218
+ /* -------------------------------------------------------------------------- */
219
+ /* Storybook story source lookup */
220
+ /* -------------------------------------------------------------------------- */
221
+
222
+ // Case-insensitive recursive search for `${name}.stories.tsx` under the
223
+ // staged story sources (see storybook.mjs's buildStorybook()).
224
+ function findStorySource(name) {
225
+ if (!existsSync(SHIPPED_STORIES_SRC)) return null;
226
+ const target = `${name.toLowerCase()}.stories.tsx`;
227
+ const stack = [SHIPPED_STORIES_SRC];
228
+ while (stack.length) {
229
+ const dir = stack.pop();
230
+ for (const entry of readdirSync(dir)) {
231
+ const full = join(dir, entry);
232
+ if (statSync(full).isDirectory()) {
233
+ stack.push(full);
234
+ continue;
235
+ }
236
+ if (entry.toLowerCase() === target) return readFileSync(full, "utf8");
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ // Stories are written against the monorepo (@repo/rb-components…). Point those imports at the
243
+ // package a plugin actually has, and flag names plugin-ui does not re-export.
244
+ function rewriteStoryImports(source, exportNames) {
245
+ const exported = new Set(exportNames);
246
+ const missing = new Set();
247
+ const text = source.replace(
248
+ /import\s+(type\s+)?\{([^}]*)\}\s*from\s*["']@repo\/rb-components[^"']*["'];?/g,
249
+ (_, typeOnly = "", names) => {
250
+ const list = names.split(",").map((n) => n.trim()).filter(Boolean);
251
+ for (const n of list) if (!exported.has(n.replace(/^type\s+/, "").split(/\s+as\s+/)[0])) missing.add(n);
252
+ return `import ${typeOnly}{ ${list.join(", ")} } from "@rainbow-robotics/plugin-ui/react";`;
253
+ },
254
+ );
255
+ const notes = [
256
+ "// Storybook story source. In a plugin, import components from \"@rainbow-robotics/plugin-ui/react\"",
257
+ "// (rewritten below); @storybook/* imports, Meta/StoryObj and play() are Storybook-only.",
258
+ ...(missing.size ? [`// Not exported by plugin-ui, do not use in a plugin: ${[...missing].join(", ")}`] : []),
259
+ ];
260
+ return `${notes.join("\n")}\n\n${text}`;
261
+ }
262
+
263
+ const editDistance = (a, b) => {
264
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
265
+ for (let i = 1; i <= a.length; i++) {
266
+ let prev = row[0]++;
267
+ for (let j = 1; j <= b.length; j++) {
268
+ const cur = row[j];
269
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
270
+ prev = cur;
271
+ }
272
+ }
273
+ return row[b.length];
274
+ };
275
+
276
+ // "RBSelect" → RBSelectBox, RBSelectOption, …; "select" (a native element) → its plugin-ui replacement.
277
+ function suggestExports(name, exportNames) {
278
+ const q = name.toLowerCase().replace(/[<>]/g, "");
279
+ const native = NATIVE_TO_RB.filter(([el]) => el.split(/[\s/=]+/).includes(q)).map(([, rb]) => rb);
280
+ const bare = q.replace(/^rb/, "");
281
+ const close = exportNames
282
+ .map((n) => {
283
+ const lower = n.toLowerCase();
284
+ const score = lower.includes(q) || (bare.length > 2 && lower.includes(bare)) ? 0 : editDistance(q, lower);
285
+ return { n, score };
286
+ })
287
+ .filter((x) => x.score <= 3)
288
+ .sort((a, b) => a.score - b.score || a.n.length - b.n.length)
289
+ .slice(0, 6)
290
+ .map((x) => x.n);
291
+ return [...native, ...close];
292
+ }
293
+
294
+ function unknownExportText(name, exportNames, what) {
295
+ const hints = suggestExports(name, exportNames);
296
+ return (
297
+ `No ${what} named "${name}" in @rainbow-robotics/plugin-ui/react.` +
298
+ (hints.length ? ` Did you mean: ${hints.join(", ")}?` : "") +
299
+ " Call list_components for valid names. Do not fall back to a native HTML element."
300
+ );
301
+ }
302
+
303
+ /* -------------------------------------------------------------------------- */
304
+ /* README search */
305
+ /* -------------------------------------------------------------------------- */
306
+
307
+ // Split the README on headings and keyword-score each section.
308
+ // Headings inside fenced code blocks (```…```) don't count — a column-0
309
+ // Python comment would otherwise start a bogus section.
310
+ function splitSections(text) {
311
+ const sections = [];
312
+ let current = [];
313
+ let inFence = false;
314
+ for (const line of text.split("\n")) {
315
+ if (/^\s*```/.test(line)) inFence = !inFence;
316
+ if (!inFence && /^#{1,4} /.test(line) && current.length) {
317
+ sections.push(current.join("\n"));
318
+ current = [];
319
+ }
320
+ current.push(line);
321
+ }
322
+ if (current.length) sections.push(current.join("\n"));
323
+ return sections;
324
+ }
325
+
326
+ // Standalone binaries have no templates/ on disk.
327
+ async function readTemplateReadme(rel) {
328
+ const onDisk = join(kitRoot, "cli/templates", rel);
329
+ if (existsSync(onDisk)) return readFileSync(onDisk, "utf8");
330
+ try {
331
+ const { TEMPLATES } = await import("./templates-embedded.mjs");
332
+ return TEMPLATES[rel] ? Buffer.from(TEMPLATES[rel], "base64").toString("utf8") : "";
333
+ } catch {
334
+ return "";
335
+ }
336
+ }
337
+
338
+ async function searchReadme(query, lang) {
339
+ const langs = lang ? [lang] : ["python", "cpp"];
340
+ const seen = new Set();
341
+ const sections = [];
342
+ for (const l of langs) {
343
+ for (const section of splitSections(await readTemplateReadme(TEMPLATE_READMES[l]))) {
344
+ if (seen.has(section)) continue; // UI/manifest sections are shared by both READMEs
345
+ seen.add(section);
346
+ sections.push(langs.length > 1 && l === "cpp" ? `[C++ backend]\n${section}` : section);
347
+ }
348
+ }
349
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
350
+
351
+ return sections
352
+ .map((section) => {
353
+ const lower = section.toLowerCase();
354
+ const score = terms.reduce((n, term) => n + (lower.includes(term) ? 1 : 0), 0);
355
+ return { section: section.trim(), score };
356
+ })
357
+ .filter((x) => x.score > 0)
358
+ .sort((a, b) => b.score - a.score)
359
+ .slice(0, 5)
360
+ .map((x) => x.section);
361
+ }
362
+
363
+ /* -------------------------------------------------------------------------- */
364
+ /* Design tokens — parsed from rb-styles' generated rb-token.css */
365
+ /* -------------------------------------------------------------------------- */
366
+
367
+ // A color var `--color-<name>` becomes Tailwind utilities `bg-<name>`,
368
+ // `text-<name>`, `border-<name>`, `fill-<name>`. The design system's naming
369
+ // already encodes intent (bg-/fg-/border-/fill-), so we surface the idiomatic
370
+ // class per group.
371
+ const COLOR_CLASS_HINT = {
372
+ bg: (n) => `bg-${n}`, // --color-bg-default → bg-bg-default
373
+ fg: (n) => `text-${n}`, // --color-fg-subtle → text-fg-subtle
374
+ border: (n) => `border-${n}`,
375
+ fill: (n) => `fill-${n}`,
376
+ };
377
+
378
+ let tokensCache = null;
379
+
380
+ function loadDesignTokens() {
381
+ if (tokensCache) return tokensCache;
382
+ if (!existsSync(DESIGN_TOKENS_CSS)) {
383
+ throw new Error(
384
+ `design token CSS not found at ${DESIGN_TOKENS_CSS} — rebuild via "muscat-pk storybook --rebuild" (monorepo) or reinstall.`,
385
+ );
386
+ }
387
+ const css = readFileSync(DESIGN_TOKENS_CSS, "utf8");
388
+
389
+ // @theme { --name: value; ... } → { group, name, value, class? }
390
+ const vars = [];
391
+ for (const [, rawName, value] of css.matchAll(
392
+ /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim,
393
+ )) {
394
+ const name = rawName.replace(/^--/, "");
395
+ const m = name.match(/^color-(bg|fg|border|fill)-(.+)$/);
396
+ const token = { name, value: value.trim() };
397
+ if (m) {
398
+ token.group = `color-${m[1]}`;
399
+ token.class = COLOR_CLASS_HINT[m[1]](`${m[1]}-${m[2]}`);
400
+ } else {
401
+ token.group = name.split("-").slice(0, 2).join("-");
402
+ }
403
+ vars.push(token);
404
+ }
405
+
406
+ // @utility typo-… → composite typography classes used directly in className.
407
+ const typo = [
408
+ ...new Set(
409
+ [...css.matchAll(/@utility\s+(typo-[a-z0-9-]+)\s*\{/g)].map((m) => m[1]),
410
+ ),
411
+ ].sort();
412
+
413
+ tokensCache = { vars, typo };
414
+ return tokensCache;
415
+ }
416
+
417
+ /* -------------------------------------------------------------------------- */
418
+ /* rainbow-rb-sdk (Python) introspection */
419
+ /* -------------------------------------------------------------------------- */
420
+
421
+ function pythonCmd() {
422
+ // Prefer the current plugin's own .venv — that's where `muscat-pk new` /
423
+ // ensureIdeVenv() installs rainbow-rb-sdk from requirements.txt. The AI
424
+ // agent's cwd is usually the plugin dir mcp was launched from.
425
+ try {
426
+ const py = venvPython(resolve(findPluginRoot(), ".venv"));
427
+ if (existsSync(py)) return py;
428
+ } catch {
429
+ /* not inside a plugin project — fall through to PATH lookup */
430
+ }
431
+ for (const c of ["python3", "python"]) {
432
+ const r = spawnSync(c, ["--version"], { encoding: "utf8" });
433
+ if (r.status === 0) return c;
434
+ }
435
+ return null;
436
+ }
437
+
438
+ // Run an introspection script against the installed rainbow.rb_sdk (a plugin's
439
+ // backend dependency). Returns a clear hint if it isn't importable.
440
+ function introspectRbSdk(script) {
441
+ const py = pythonCmd();
442
+ const probe = `
443
+ import json, importlib
444
+ try:
445
+ importlib.import_module("rainbow.rb_sdk")
446
+ except Exception as e:
447
+ print(json.dumps({"__notinstalled__": str(e)})); raise SystemExit(0)
448
+ ${script}
449
+ `;
450
+ if (!py) {
451
+ return introspectInDocker(probe) ?? { error: "Python not found on PATH. Install Python 3 to use rb-sdk introspection." };
452
+ }
453
+ const r = spawnSync(py, ["-c", probe], { encoding: "utf8", maxBuffer: 8 << 20 });
454
+ if (r.status !== 0) {
455
+ return { error: `python introspection failed: ${(r.stderr || "").trim().slice(0, 500)}` };
456
+ }
457
+ const local = parseProbe(r);
458
+ return local.error && local.notInstalled ? introspectInDocker(probe) ?? local : local;
459
+ }
460
+
461
+ // No macOS wheels: import the plugin's pinned SDK inside the python dev image (cached).
462
+ function introspectInDocker(probe) {
463
+ if (spawnSync("docker", ["image", "inspect", PYTHON_DEV_IMAGE], { encoding: "utf8" }).status !== 0) return null;
464
+ let ver = "";
465
+ try {
466
+ const req = readFileSync(join(findPluginRoot(), "requirements.txt"), "utf8");
467
+ ver = req.match(/^rainbow-rb-sdk==(\S+)/m)?.[1] ?? "";
468
+ } catch {
469
+ /* not a python plugin — latest */
470
+ }
471
+ // ponytail: "latest" cache is never refreshed; delete ~/.cache/... rb-sdk-py/latest to update.
472
+ const cache = join(UI_INDEX_CACHE_DIR, "rb-sdk-py", ver || "latest");
473
+ mkdirSync(cache, { recursive: true });
474
+ const pkg = ver ? `rainbow-rb-sdk==${ver}` : "rainbow-rb-sdk";
475
+ const script =
476
+ `[ -f /c/.ok ] || { pip3 install -q --break-system-packages --target /c '${pkg}' >&2 && touch /c/.ok; } && ` +
477
+ `PYTHONPATH=/c python3 -c "$PROBE"`;
478
+ const r = spawnSync("docker", ["run", "--rm", "-v", `${cache}:/c`, "-e", "PROBE", PYTHON_DEV_IMAGE, "bash", "-c", script], {
479
+ encoding: "utf8",
480
+ maxBuffer: 8 << 20,
481
+ env: { ...process.env, PROBE: probe },
482
+ });
483
+ if (r.status !== 0) return { error: `python introspection (docker) failed: ${(r.stderr || "").trim().slice(-500)}` };
484
+ return parseProbe(r);
485
+ }
486
+
487
+ function parseProbe(r) {
488
+ // Importing rainbow.rb_sdk has side effects that print to stdout (e.g. the
489
+ // Zenoh client logs its connection endpoints), ahead of our own final
490
+ // print(json.dumps(...)) — so parse the last line, not the whole output.
491
+ const lines = r.stdout.trim().split("\n");
492
+ let data;
493
+ try {
494
+ data = JSON.parse(lines[lines.length - 1]);
495
+ } catch {
496
+ return { error: `unexpected introspection output: ${r.stdout.slice(0, 300)}` };
497
+ }
498
+ if (data && data.__notinstalled__) {
499
+ return {
500
+ notInstalled: true,
501
+ error:
502
+ "rainbow-rb-sdk is not installed in this environment. Install it with `pip install rainbow-rb-sdk` " +
503
+ "(it's already in a plugin's requirements.txt), then retry.",
504
+ };
505
+ }
506
+ return { data };
507
+ }
508
+
509
+ /* -------------------------------------------------------------------------- */
510
+ /* rainbow-rb-sdk-dev (C++) headers — copied out of the cpp dev image */
511
+ /* -------------------------------------------------------------------------- */
512
+
513
+ const CPP_INCLUDE_CACHE = join(UI_INDEX_CACHE_DIR, "rb-sdk-include");
514
+ // Headers we don't want to surface: third-party vendored code, pybind glue,
515
+ // flatbuffers-generated schemas, and the industrial comm stacks.
516
+ const CPP_SKIP = /^(vendor|binding|rb_comm_source)\/|_generated\.h$/;
517
+
518
+ // The apt package is only installed inside the muscat-pk-cpp-dev image, so
519
+ // copy /usr/include/rainbow/rb_sdk out once per image build (cache key =
520
+ // image id) with `docker cp` — no host tar needed.
521
+ function cppIncludeDir() {
522
+ const insp = spawnSync("docker", ["image", "inspect", "-f", "{{.Id}}", CPP_DEV_IMAGE], { encoding: "utf8" });
523
+ if (insp.error) return { error: "Docker not found — the C++ SDK headers live in the muscat-pk cpp dev image." };
524
+ if (insp.status !== 0) {
525
+ return {
526
+ error:
527
+ `Docker image ${CPP_DEV_IMAGE} not built yet. Run \`muscat-pk dev:back\` (or \`muscat-pk build\`) once ` +
528
+ "in a cpp / rclcpp plugin to build it, then retry.",
529
+ };
530
+ }
531
+ const dir = join(CPP_INCLUDE_CACHE, insp.stdout.trim().replace(/^sha256:/, "").slice(0, 12));
532
+ const out = join(dir, "rb_sdk");
533
+ if (existsSync(out)) return { dir: out };
534
+ mkdirSync(dir, { recursive: true });
535
+ const ctr = spawnSync("docker", ["create", CPP_DEV_IMAGE], { encoding: "utf8" });
536
+ if (ctr.status !== 0) return { error: `docker create failed: ${(ctr.stderr || "").trim().slice(0, 300)}` };
537
+ const id = ctr.stdout.trim();
538
+ const cp = spawnSync("docker", ["cp", `${id}:/usr/include/rainbow/rb_sdk`, dir], { encoding: "utf8" });
539
+ spawnSync("docker", ["rm", "-f", id], { encoding: "utf8" });
540
+ if (cp.status !== 0 || !existsSync(out)) {
541
+ return { error: `docker cp failed: ${(cp.stderr || "").trim().slice(0, 300)}` };
542
+ }
543
+ return { dir: out };
544
+ }
545
+
546
+ function cppHeaders(root) {
547
+ const files = [];
548
+ const walk = (d, rel) => {
549
+ for (const name of readdirSync(d)) {
550
+ const r = rel ? `${rel}/${name}` : name;
551
+ if (CPP_SKIP.test(r)) continue;
552
+ const full = join(d, name);
553
+ if (statSync(full).isDirectory()) walk(full, r);
554
+ else if (/\.hpp$/.test(name)) files.push(r);
555
+ }
556
+ };
557
+ walk(root, "");
558
+ return files.sort();
559
+ }
560
+
561
+ // "class Foo {" / "class Foo : public Bar {" — not forward declarations.
562
+ const CLASS_RE = /^\s*(?:class|struct)\s+(?:[A-Z][A-Z0-9_]*_API\s+)?(\w+)\b(?![^\n]*;\s*$)/; // skip export macros (RB_SDK_API)
563
+
564
+ function cppClassesIn(text) {
565
+ return [...new Set(text.split("\n").map((l) => l.match(CLASS_RE)?.[1]).filter(Boolean))];
566
+ }
567
+
568
+ // Return the class body (brace-matched) plus the file's leading comment block,
569
+ // which in these headers usually carries a full usage example.
570
+ function cppExtract(text, className, method) {
571
+ const lines = text.split("\n");
572
+ const start = lines.findIndex((l) => CLASS_RE.test(l) && l.match(CLASS_RE)[1] === className);
573
+ if (start < 0) return null;
574
+ let depth = 0, end = start, seen = false;
575
+ for (let i = start; i < lines.length; i++) {
576
+ for (const ch of lines[i]) {
577
+ if (ch === "{") { depth++; seen = true; }
578
+ else if (ch === "}") depth--;
579
+ }
580
+ if (seen && depth === 0) { end = i; break; }
581
+ }
582
+ const body = lines.slice(start, end + 1);
583
+ if (method) {
584
+ // Declarations sit at class-member indent (<= 2 spaces) and the name is
585
+ // not preceded by `.`/`->`/`::` — that skips call sites inside bodies.
586
+ const re = new RegExp(`^ {0,2}[^(]*(?<![\\w.>:])${method.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`);
587
+ const hits = [];
588
+ for (let i = 0; i < body.length; i++) {
589
+ if (!re.test(body[i]) || /^\s*\/\//.test(body[i])) continue;
590
+ let a = i;
591
+ while (a > 0 && /^\s*\/\//.test(body[a - 1])) a--;
592
+ let b = i;
593
+ while (b < body.length - 1 && !/[{;]/.test(body[b])) b++;
594
+ hits.push(body.slice(a, b + 1).join("\n"));
595
+ }
596
+ return hits.length ? hits.join("\n\n") : null;
597
+ }
598
+ const head = [];
599
+ for (const l of lines) {
600
+ if (/^\s*\/\//.test(l)) head.push(l);
601
+ else if (l.trim() === "" || /^#pragma/.test(l)) continue;
602
+ else break;
603
+ }
604
+ return (head.length > 3 ? head.join("\n") + "\n\n" : "") + body.join("\n");
605
+ }
606
+
607
+ function cppFind(className) {
608
+ const { dir, error } = cppIncludeDir();
609
+ if (error) return { error };
610
+ for (const rel of cppHeaders(dir)) {
611
+ const text = readFileSync(join(dir, rel), "utf8");
612
+ if (cppClassesIn(text).includes(className)) return { rel, text };
613
+ }
614
+ return { error: `No class "${className}" in the C++ SDK headers. Call list_rb_sdk with lang "cpp" for valid names.` };
615
+ }
616
+
617
+ /* -------------------------------------------------------------------------- */
618
+ /* client registration — `muscat-pk mcp --install <client>` */
619
+ /* -------------------------------------------------------------------------- */
620
+
621
+ // Claude Code needs no install step: `muscat-pk new` scaffolds a project-level
622
+ // .mcp.json. Codex only reads the global ~/.codex/config.toml, so we append
623
+ // the server block there (idempotent).
624
+ export function installMcp(target) {
625
+ if (target !== "codex") {
626
+ throw new Error(
627
+ `Unknown --install target ${JSON.stringify(target ?? "")} — supported: "codex". ` +
628
+ "(Claude Code works out of the box via the scaffolded .mcp.json.)",
629
+ );
630
+ }
631
+ const cfgDir = join(homedir(), ".codex");
632
+ const cfgPath = join(cfgDir, "config.toml");
633
+ const existing = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
634
+ if (/^\s*\[mcp_servers\.muscat\]/m.test(existing)) {
635
+ console.log(`Codex already has the muscat MCP server registered (${cfgPath}) — nothing to do.`);
636
+ return;
637
+ }
638
+ // ponytail: Windows Codex spawns without a shell, so it needs the .cmd shim.
639
+ const command = process.platform === "win32" ? "muscat-pk.cmd" : "muscat-pk";
640
+ mkdirSync(cfgDir, { recursive: true });
641
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
642
+ appendFileSync(
643
+ cfgPath,
644
+ `${sep}[mcp_servers.muscat]\ncommand = "${command}"\nargs = ["mcp"]\n`,
645
+ );
646
+ console.log(`Registered the muscat MCP server for Codex → ${cfgPath}`);
647
+ console.log("Restart Codex (or start a new session) to pick it up.");
648
+ }
649
+
650
+ /* -------------------------------------------------------------------------- */
651
+ /* server */
652
+ /* -------------------------------------------------------------------------- */
653
+
654
+ export async function runMcp() {
655
+ const server = new McpServer(
656
+ { name: "muscat-plugin-kit", version: kitVersion() },
657
+ { instructions: SERVER_INSTRUCTIONS },
658
+ );
659
+
660
+ server.registerTool(
661
+ "list_components",
662
+ {
663
+ title: "List Muscat plugin UI components",
664
+ description:
665
+ "List every component, hook, and type exported from @rainbow-robotics/plugin-ui/react — " +
666
+ "the full UI surface available to Muscat plugin authors. Call this first before guessing a " +
667
+ "component name; plugin UI uses these instead of native <select>/<input>/<button> elements.",
668
+ inputSchema: {},
669
+ },
670
+ async () => {
671
+ const { components } = await loadUiIndex();
672
+ return { content: [{ type: "text", text: components }] };
673
+ },
674
+ );
675
+
676
+ server.registerTool(
677
+ "get_component_props",
678
+ {
679
+ title: "Get a component or hook's real type signature",
680
+ description:
681
+ "Get the full TypeScript type for a named export of @rainbow-robotics/plugin-ui/react " +
682
+ "(e.g. RBSlider, RBLabelButton, useRb, useRbFormData). Use list_components to find valid names.",
683
+ inputSchema: { name: z.string().describe('Exported name, e.g. "RBSlider"') },
684
+ },
685
+ async ({ name }) => {
686
+ const { props } = await loadUiIndex();
687
+ if (!Object.hasOwn(props, name)) {
688
+ return { isError: true, content: [{ type: "text", text: unknownExportText(name, Object.keys(props), "export") }] };
689
+ }
690
+ return { content: [{ type: "text", text: props[name] }] };
691
+ },
692
+ );
693
+
694
+ server.registerTool(
695
+ "get_component_example",
696
+ {
697
+ title: "Get a real Storybook usage example",
698
+ description:
699
+ "Get the real Storybook story source for a component (e.g. RBSlider) — actual working " +
700
+ "props, imports, and layout, not a guessed shape. Prefer this over get_component_props when " +
701
+ "you just need to know how to use a component, not its full type.",
702
+ // `component` accepted as an alias — agents often guess it over `name`.
703
+ inputSchema: {
704
+ name: z.string().optional().describe('Component name, e.g. "RBSlider"'),
705
+ component: z.string().optional().describe("Alias for `name`."),
706
+ },
707
+ },
708
+ async ({ name, component }) => {
709
+ const target = name ?? component;
710
+ if (!target) {
711
+ return {
712
+ isError: true,
713
+ content: [{ type: "text", text: 'Missing required argument: "name" (component name, e.g. "RBSlider").' }],
714
+ };
715
+ }
716
+ const exportNames = Object.keys((await loadUiIndex()).props);
717
+ const source = findStorySource(target);
718
+ if (!source) {
719
+ // An existing export without a story still has a type; an unknown name gets suggestions.
720
+ const text = exportNames.includes(target)
721
+ ? `No Storybook example for "${target}". Call get_component_props for its type, or run "muscat-pk storybook" to browse components live.`
722
+ : unknownExportText(target, exportNames, "component");
723
+ return { content: [{ type: "text", text }] };
724
+ }
725
+ return { content: [{ type: "text", text: rewriteStoryImports(source, exportNames) }] };
726
+ },
727
+ );
728
+
729
+ server.registerTool(
730
+ "search_rb_sdk",
731
+ {
732
+ title: "Search the Muscat plugin-author docs",
733
+ description:
734
+ "Search the Muscat plugin-author README for how to use useRb(), rb.pub/sub/action/serve, " +
735
+ "plugin.router (external endpoints: router.on/subscribe/emit exposed over REST/Socket.IO/TCP/" +
736
+ "MQTT/WebRTC at common/plugin/{plugin_id}/{path}, PluginRouteError, meta.transport/principal), " +
737
+ "rb.toast/alert/confirm/prompt, useRbFormData/useRbSave (save/restore form data through the " +
738
+ "host), manifest.json (including the \"variables\" declaration for the pyfm variable picker — " +
739
+ "names require the RBP_ prefix), program variables & executor state (ctx.get_variable/" +
740
+ "set_variable/executor_state/check_stop), video streaming (MediaMTX: backend publishes RTSP to " +
741
+ "127.0.0.1:8554, UI plays WebRTC from :8889), ROS 2 topics (plugin namespace, rb.rosPeers to allow " +
742
+ "an external PC), contribution points, and backend runtimes " +
743
+ "(Python/C++/ROS2). Covers BOTH backends: the Python README (py/main.py, rainbow.rb_sdk) and the " +
744
+ "C++ README (cpp/main.cpp or an rclcpp node: rb_plugin::RBPluginCall serve/subscribe/publish/" +
745
+ "router(), rb_plugin::PluginProgramContext, RBManipulateSDK, CMake rb_sdk::rb_sdk). Pass lang " +
746
+ "\"python\" or \"cpp\" to match the plugin's manifest runtime (cpp or ros2+rclcpp → \"cpp\"); " +
747
+ "omit it to search both. IMPORTANT: " +
748
+ "the backend pattern depends on the contribution point — search 'which backend pattern' before " +
749
+ "writing the backend. Only a program.panel command uses PluginProgramContext; a toolbar/move.panel " +
750
+ "plugin controls the robot with the SDK (RBManipulateSDK/RBAmrSDK) directly.",
751
+ inputSchema: {
752
+ query: z.string().describe('Keywords, e.g. "save form data" or "rb.action"'),
753
+ lang: z.enum(["python", "cpp"]).optional().describe("Backend language; omit to search both"),
754
+ },
755
+ },
756
+ async ({ query, lang }) => {
757
+ const sections = await searchReadme(query, lang);
758
+ if (sections.length === 0) {
759
+ return { content: [{ type: "text", text: `No matching section found for "${query}".` }] };
760
+ }
761
+ return { content: [{ type: "text", text: sections.join("\n\n---\n\n") }] };
762
+ },
763
+ );
764
+
765
+ /* --- design tokens --- */
766
+
767
+ server.registerTool(
768
+ "list_design_tokens",
769
+ {
770
+ title: "List Muscat design tokens (Tailwind classes)",
771
+ description:
772
+ "List the design system's tokens as the Tailwind classes to use in a plugin's className: " +
773
+ "semantic colors (bg-bg-*, text-fg-*, border-border-*, fill-fill-*), typography (typo-*), " +
774
+ "shadows, radius, spacing. Use these instead of raw hex/px so the plugin matches the host theme " +
775
+ "(and light/dark). Optionally filter by category.",
776
+ inputSchema: {
777
+ category: z
778
+ .enum(["bg", "fg", "border", "fill", "typography", "shadow", "radius", "spacing", "all"])
779
+ .optional()
780
+ .describe('Filter, e.g. "bg", "fg", "typography". Omit for a grouped overview.'),
781
+ },
782
+ },
783
+ async ({ category }) => {
784
+ const { vars, typo } = loadDesignTokens();
785
+ const out = [];
786
+
787
+ const colorGroups = { bg: [], fg: [], border: [], fill: [] };
788
+ const other = { shadow: [], radius: [], spacing: [] };
789
+ for (const t of vars) {
790
+ const m = t.group?.match(/^color-(bg|fg|border|fill)$/);
791
+ // Class name only: the value is just an indirection to another CSS var
792
+ // (var(--color-scale-zinc-200)), and each class is declared twice (light/dark)
793
+ // with different values — dropping the value lets the Set dedup collapse them.
794
+ if (m && t.class) colorGroups[m[1]].push(t.class);
795
+ else if (t.name.startsWith("shadow-")) other.shadow.push(`shadow-${t.name.slice(7)}`);
796
+ else if (t.name.startsWith("radius-")) other.radius.push(`rounded-${t.name.slice(7)}`);
797
+ else if (t.name.startsWith("spacing-")) other.spacing.push(t.name);
798
+ }
799
+
800
+ const want = (c) => !category || category === "all" || category === c;
801
+ const push = (title, items) => {
802
+ if (items.length) out.push(`## ${title}\n${[...new Set(items)].sort().join("\n")}`);
803
+ };
804
+ if (want("bg")) push("Background — bg-bg-*", colorGroups.bg);
805
+ if (want("fg")) push("Text/foreground — text-fg-*", colorGroups.fg);
806
+ if (want("border")) push("Border — border-border-*", colorGroups.border);
807
+ if (want("fill")) push("SVG fill — fill-fill-*", colorGroups.fill);
808
+ if (want("typography")) push("Typography — typo-* (use directly)", typo);
809
+ if (want("shadow")) push("Shadow", other.shadow);
810
+ if (want("radius")) push("Radius", other.radius);
811
+ if (want("spacing")) push("Spacing scale (p-/m-/gap-/w-/h-)", other.spacing);
812
+
813
+ return {
814
+ content: [
815
+ {
816
+ type: "text",
817
+ text: out.join("\n\n") || `No tokens for category "${category}".`,
818
+ },
819
+ ],
820
+ };
821
+ },
822
+ );
823
+
824
+ server.registerTool(
825
+ "search_design_tokens",
826
+ {
827
+ title: "Search design tokens by name or value",
828
+ description:
829
+ "Find a design token by keyword — e.g. 'danger' (error colors), 'elevated', 'brand', or a hex " +
830
+ "like '#fb2c36'. Returns the matching Tailwind class + value.",
831
+ inputSchema: { query: z.string().describe('e.g. "danger", "elevated", "#fb2c36"') },
832
+ },
833
+ async ({ query }) => {
834
+ const { vars, typo } = loadDesignTokens();
835
+ const q = query.toLowerCase();
836
+ const hits = vars
837
+ .filter((t) => t.name.toLowerCase().includes(q) || t.value.toLowerCase().includes(q))
838
+ .map((t) => `${t.class ?? t.name} (${t.value})`);
839
+ const typoHits = typo.filter((c) => c.toLowerCase().includes(q));
840
+ const all = [...typoHits, ...hits];
841
+ return {
842
+ content: [
843
+ {
844
+ type: "text",
845
+ text: all.length ? [...new Set(all)].slice(0, 60).join("\n") : `No token matches "${query}".`,
846
+ },
847
+ ],
848
+ };
849
+ },
850
+ );
851
+
852
+ /* --- rainbow-rb-sdk (Python backend) --- */
853
+
854
+ server.registerTool(
855
+ "list_rb_sdk",
856
+ {
857
+ title: "List rainbow-rb-sdk classes and functions (Python or C++)",
858
+ description:
859
+ "List the public classes and functions of the plugin BACKEND SDK. lang \"python\" (default): the " +
860
+ "installed rainbow.rb_sdk package (RBPluginCall, RBManipulateSDK, RBAmrSDK, PluginProgramContext, " +
861
+ "...) — call before writing py/main.py; requires rainbow-rb-sdk to be pip-installed. lang \"cpp\": " +
862
+ "the C++ SDK headers (apt rainbow-rb-sdk-dev: RBManipulateSDK, rb_plugin::PluginProgramContext, " +
863
+ "...) grouped by header path — call before writing cpp/main.cpp or an rclcpp node; requires the " +
864
+ "muscat-pk-cpp-dev Docker image (built by `muscat-pk dev:back`). RBPluginCall (UI pub/sub/serve, " +
865
+ "router) exists in both: Python rainbow.rb_sdk.RBPluginCall, C++ rb_plugin::RBPluginCall " +
866
+ "(plugin/rb_plugin.hpp).",
867
+ inputSchema: { lang: z.enum(["python", "cpp"]).optional().describe('Default "python"') },
868
+ },
869
+ async ({ lang }) => {
870
+ if (lang === "cpp") {
871
+ const { dir, error } = cppIncludeDir();
872
+ if (error) return { isError: true, content: [{ type: "text", text: error }] };
873
+ const out = [];
874
+ for (const rel of cppHeaders(dir)) {
875
+ const classes = cppClassesIn(readFileSync(join(dir, rel), "utf8"));
876
+ if (classes.length) out.push(`${rel}: ${classes.join(", ")}`);
877
+ }
878
+ return { content: [{ type: "text", text: `#include <rainbow/rb_sdk/...>\n\n${out.join("\n")}` }] };
879
+ }
880
+ const { data, error } = introspectRbSdk(`
881
+ import inspect, json
882
+ mod = importlib.import_module("rainbow.rb_sdk")
883
+ out = []
884
+ for name in sorted(getattr(mod, "__all__", None) or dir(mod)):
885
+ if name.startswith("_"): continue
886
+ obj = getattr(mod, name)
887
+ if inspect.isclass(obj): out.append(name + " (class)")
888
+ elif inspect.isfunction(obj) or inspect.isbuiltin(obj): out.append(name + " (function)")
889
+ print(json.dumps(out))
890
+ `);
891
+ if (error) return { isError: true, content: [{ type: "text", text: error }] };
892
+ return { content: [{ type: "text", text: data.join("\n") }] };
893
+ },
894
+ );
895
+
896
+ server.registerTool(
897
+ "get_rb_sdk_api",
898
+ {
899
+ title: "Get a rainbow-rb-sdk class/function signature + docs (Python or C++)",
900
+ description:
901
+ "Get the signature(s) and docstring for a rainbow.rb_sdk class or function (e.g. " +
902
+ "'PluginProgramContext', 'RBManipulateSDK'). For a class, lists its public methods with " +
903
+ "signatures. To drill into ONE method of a class (e.g. RBManipulateSDK.call_move_j), pass " +
904
+ "`method` alongside `name` — do not put both in `name`. lang \"python\" (default) introspects the " +
905
+ "pip-installed package. lang \"cpp\" returns the class declaration from the C++ SDK header " +
906
+ "(rainbow-rb-sdk-dev) with its header comment — usually a full usage example — and the " +
907
+ "#include path; with `method`, just that method's declaration and comment.",
908
+ inputSchema: {
909
+ name: z.string().describe('Class or function name, e.g. "RBManipulateSDK"'),
910
+ method: z.string().optional().describe('Optional: a specific method on that class, e.g. "call_move_j"'),
911
+ lang: z.enum(["python", "cpp"]).optional().describe('Default "python"'),
912
+ },
913
+ },
914
+ async ({ name, method, lang }) => {
915
+ // Tolerate a combined "ClassName method_name" in `name` (a common agent
916
+ // mistake) by splitting it ourselves instead of failing validation.
917
+ let className = name;
918
+ let methodName = method;
919
+ if (!methodName && /\s/.test(name.trim())) {
920
+ const parts = name.trim().split(/\s+/);
921
+ if (parts.length === 2) [className, methodName] = parts;
922
+ }
923
+
924
+ if (lang === "cpp") {
925
+ const cls = className.replace(/^.*::/, "");
926
+ let { rel, text, error } = cppFind(cls);
927
+ if (error) return { isError: true, content: [{ type: "text", text: error }] };
928
+ let body = cppExtract(text, cls, methodName);
929
+ let owner = cls;
930
+ if (!body && methodName) {
931
+ // Like the Python path: most calls live on sub-SDK members
932
+ // (RBManipulateMoveSDK move; -> sdk.move.call_move_j) or on the base
933
+ // class — look there before giving up.
934
+ const decl = cppExtract(text, cls);
935
+ const cands = [
936
+ ...[...decl.matchAll(/^\s*(\w+SDK)\s+(\w+);/gm)].map((m) => [m[1], `${cls}.${m[2]}`]),
937
+ ...[...decl.matchAll(/:\s*public\s+(\w+)/g)].map((m) => [m[1], `${cls} (base ${m[1]})`]),
938
+ ];
939
+ for (const [c, label] of cands) {
940
+ const f = cppFind(c);
941
+ if (f.error) continue;
942
+ const b = cppExtract(f.text, c, methodName);
943
+ if (b) { ({ rel, text } = f); body = b; owner = label; break; }
944
+ }
945
+ }
946
+ if (!body) {
947
+ return {
948
+ isError: true,
949
+ content: [{ type: "text", text: `No method "${methodName}" declared in class "${cls}" (${rel}) or its sub-SDK members / base class. Call get_rb_sdk_api with just \`name\` to see the class.` }],
950
+ };
951
+ }
952
+ const ns = text.match(/^namespace\s+(\w+)\s*\{/m)?.[1];
953
+ const header =
954
+ `// #include <rainbow/rb_sdk/${rel}>${ns ? ` (namespace ${ns})` : ""}\n` +
955
+ `// link: find_package(rb_sdk) + rb_sdk::rb_sdk\n` +
956
+ (methodName ? `// ${owner}.${methodName}\n` : "") + "\n";
957
+ return { content: [{ type: "text", text: header + body.slice(0, 60_000) }] };
958
+ }
959
+
960
+ const { data, error } = introspectRbSdk(`
961
+ import inspect, json
962
+ mod = importlib.import_module("rainbow.rb_sdk")
963
+ name = ${JSON.stringify(className)}
964
+ method = ${methodName ? JSON.stringify(methodName) : "None"}
965
+ obj = getattr(mod, name, None)
966
+ if obj is None:
967
+ print(json.dumps({"__missing__": True})); raise SystemExit(0)
968
+ def sig(o):
969
+ try: return str(inspect.signature(o))
970
+ except Exception: return "(...)"
971
+
972
+ if method:
973
+ # Direct method on the class itself.
974
+ fn = getattr(obj, method, None) if inspect.isclass(obj) else None
975
+ # rb_sdk classes often group calls under a nested namespace set in
976
+ # __init__ (e.g. RBManipulateSDK().move.call_move_j) — that only exists on
977
+ # an INSTANCE, which we won't construct (side effects, needs a robot
978
+ # connection). Best-effort: search the class's own __init__ source text
979
+ # for self.<attr> = <NestedClass>(...) and check each nested class too.
980
+ owner = name
981
+ if fn is None and inspect.isclass(obj):
982
+ try:
983
+ src = inspect.getsource(obj.__init__)
984
+ except Exception:
985
+ src = ""
986
+ import re, sys as _sys
987
+ # The nested sub-SDK classes (e.g. RBManipulateMoveSDK) are imported in
988
+ # the module that DEFINES obj (rainbow.rb_sdk.manipulate), not in the
989
+ # rainbow.rb_sdk top-level namespace — resolve names against both.
990
+ _defmod = _sys.modules.get(getattr(obj, "__module__", ""))
991
+ for attr, nested_name in re.findall(r"self\\.(\\w+)\\s*=\\s*(\\w+)\\(", src):
992
+ nested = (
993
+ (getattr(_defmod, nested_name, None) if _defmod else None)
994
+ or mod.__dict__.get(nested_name)
995
+ or globals().get(nested_name)
996
+ )
997
+ cand = getattr(nested, method, None) if nested else None
998
+ if cand:
999
+ fn = cand
1000
+ owner = f"{name}.{attr} ({nested_name})"
1001
+ break
1002
+ if fn is None:
1003
+ print(json.dumps({"__missing__": True, "__method__": True})); raise SystemExit(0)
1004
+ print(json.dumps({
1005
+ "name": name, "kind": "method", "owner": owner,
1006
+ "signature": method + sig(fn), "doc": inspect.getdoc(fn) or "",
1007
+ }))
1008
+ raise SystemExit(0)
1009
+
1010
+ res = {"name": name, "doc": inspect.getdoc(obj) or ""}
1011
+ if inspect.isclass(obj):
1012
+ res["kind"] = "class"
1013
+ res["init"] = name + sig(obj.__init__ if "__init__" in dir(obj) else obj)
1014
+ def _methods_of(cls, prefix=""):
1015
+ out = []
1016
+ for m, fn in inspect.getmembers(cls, predicate=inspect.isfunction):
1017
+ if m.startswith("_"): continue
1018
+ out.append({"name": prefix + m, "sig": prefix + m + sig(fn),
1019
+ "doc": (inspect.getdoc(fn) or "").split("\\n")[0]})
1020
+ return out
1021
+ methods = _methods_of(obj)
1022
+ # Most rb_sdk calls live on nested sub-SDKs assigned in __init__
1023
+ # (e.g. self.move = RBManipulateMoveSDK() -> obj.move.call_move_j). List
1024
+ # those too, name-prefixed, so the API is discoverable without an instance.
1025
+ import re as _re, sys as _sys
1026
+ _defmod = _sys.modules.get(getattr(obj, "__module__", ""))
1027
+ try:
1028
+ _src = inspect.getsource(obj.__init__)
1029
+ except Exception:
1030
+ _src = ""
1031
+ for _attr, _nested_name in _re.findall(r"self\\.(\\w+)\\s*=\\s*(\\w+)\\(", _src):
1032
+ _nested = (getattr(_defmod, _nested_name, None) if _defmod else None) or mod.__dict__.get(_nested_name)
1033
+ if inspect.isclass(_nested):
1034
+ methods.extend(_methods_of(_nested, prefix=_attr + "."))
1035
+ res["methods"] = methods
1036
+ else:
1037
+ res["kind"] = "function"
1038
+ res["signature"] = name + sig(obj)
1039
+ print(json.dumps(res))
1040
+ `);
1041
+ if (error) return { isError: true, content: [{ type: "text", text: error }] };
1042
+ if (data.__missing__) {
1043
+ const text = data.__method__
1044
+ ? `No method "${methodName}" found directly on "${className}" (nor in an obvious nested ` +
1045
+ `namespace set in __init__). Call get_rb_sdk_api with just \`name: "${className}"\` to see ` +
1046
+ `its full method list and constructor first.`
1047
+ : `No "${className}" in rainbow.rb_sdk. Call list_rb_sdk for valid names.`;
1048
+ return { isError: true, content: [{ type: "text", text }] };
1049
+ }
1050
+ if (data.kind === "method") {
1051
+ const lines = [`# ${data.owner}.${data.signature.split("(")[0]}`, data.signature];
1052
+ if (data.doc) lines.push("", data.doc);
1053
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1054
+ }
1055
+ const lines = [`# ${data.name} (${data.kind})`];
1056
+ if (data.doc) lines.push(data.doc, "");
1057
+ if (data.kind === "class") {
1058
+ lines.push(`constructor: ${data.init}`, "", "methods:");
1059
+ for (const m of data.methods) {
1060
+ lines.push(` ${m.sig}${m.doc ? ` — ${m.doc}` : ""}`);
1061
+ }
1062
+ } else {
1063
+ lines.push(data.signature);
1064
+ }
1065
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1066
+ },
1067
+ );
1068
+
1069
+ const transport = new StdioServerTransport();
1070
+ await server.connect(transport);
1071
+ }