@nifrajs/cli 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The subprocess `nifra mcp` runs project code in. The server (`mcp-isolate.ts`) spawns a fresh one
3
+ * per call in the project's directory, so Bun loads that directory's `.env` files here, and the
4
+ * config, the backend and everything they import run here and exit with the process. The tools,
5
+ * resources and prompts answered here are the same ones the server lists: the project tools, and the
6
+ * ones the app declares on its backend.
7
+ *
8
+ * Protocol: one JSON request on stdin; on stdout, progress lines and then one answer line, each
9
+ * prefixed with the request's token, so anything project code prints cannot be read as an answer.
10
+ */
11
+
12
+ import { detectMonorepo } from "./load.ts"
13
+ import { projectResources } from "./mcp-context.ts"
14
+ import { createCachedAppLoader, projectTools } from "./mcp-exec.ts"
15
+ import { CHILD_INPUT_MAX_BYTES, readBoundedStream } from "./mcp-io.ts"
16
+ import type {
17
+ McpPrompt,
18
+ McpPromptMessage,
19
+ McpResource,
20
+ McpTool,
21
+ McpToolResult,
22
+ } from "./mcp-protocol.ts"
23
+ import {
24
+ extractBackendPrompts,
25
+ extractBackendResources,
26
+ extractBackendTools,
27
+ } from "./mcp-reflect.ts"
28
+
29
+ export type ProjectChildRequest =
30
+ | { readonly op: "monorepo" }
31
+ | { readonly op: "surface" }
32
+ | { readonly op: "tool"; readonly name: string; readonly args: Record<string, unknown> }
33
+ | { readonly op: "resource"; readonly uri: string }
34
+ | { readonly op: "prompt"; readonly name: string; readonly args: Record<string, unknown> }
35
+
36
+ /** What the server lists for the tools, resources and prompts the app declares on its backend. */
37
+ export interface AppSurface {
38
+ readonly tools: readonly Omit<McpTool, "handler">[]
39
+ readonly resources: readonly Omit<McpResource, "read">[]
40
+ readonly prompts: readonly Omit<McpPrompt, "handler">[]
41
+ }
42
+
43
+ export type ProjectChildAnswer =
44
+ | { readonly ok: true; readonly apps: Readonly<Record<string, string>> | null }
45
+ | { readonly ok: true; readonly surface: AppSurface }
46
+ | { readonly ok: true; readonly result: string | McpToolResult }
47
+ | { readonly ok: true; readonly resource: { readonly text: string; readonly mimeType?: string } }
48
+ | { readonly ok: true; readonly messages: readonly McpPromptMessage[] }
49
+ | { readonly ok: false; readonly message: string }
50
+
51
+ export type ProjectChildMessage =
52
+ | { readonly type: "progress"; readonly progress: number; readonly total?: number }
53
+ | { readonly type: "answer"; readonly answer: ProjectChildAnswer }
54
+
55
+ /**
56
+ * Answer `request` for the project at `cwd`. A tool, resource or prompt that throws answers
57
+ * `ok: false` with its message; an app that cannot be loaded declares nothing, as in the server.
58
+ */
59
+ export async function answerProjectRequest(
60
+ cwd: string,
61
+ request: ProjectChildRequest,
62
+ reportProgress: (progress: number, total?: number) => void = () => {},
63
+ ): Promise<ProjectChildAnswer> {
64
+ try {
65
+ if (request.op === "monorepo") {
66
+ return { ok: true, apps: (await detectMonorepo(cwd))?.apps ?? null }
67
+ }
68
+ const loadAppCached = createCachedAppLoader(cwd)
69
+ const backend = (): Promise<unknown> =>
70
+ loadAppCached().then(
71
+ (app) => app.backend,
72
+ () => undefined,
73
+ )
74
+ if (request.op === "surface") {
75
+ const declared = await backend()
76
+ return {
77
+ ok: true,
78
+ surface: {
79
+ tools: extractBackendTools(declared),
80
+ resources: extractBackendResources(declared),
81
+ prompts: extractBackendPrompts(declared),
82
+ },
83
+ }
84
+ }
85
+ if (request.op === "tool") {
86
+ const tool =
87
+ projectTools(cwd, loadAppCached).find((t) => t.name === request.name) ??
88
+ extractBackendTools(await backend()).find((t) => t.name === request.name)
89
+ if (tool === undefined) return { ok: false, message: `unknown tool: ${request.name}` }
90
+ const result = await tool.handler(request.args, {
91
+ signal: new AbortController().signal,
92
+ requestId: null,
93
+ reportProgress,
94
+ })
95
+ return { ok: true, result }
96
+ }
97
+ if (request.op === "resource") {
98
+ const resource =
99
+ projectResources(cwd, loadAppCached).find((r) => r.uri === request.uri) ??
100
+ extractBackendResources(await backend()).find((r) => r.uri === request.uri)
101
+ if (resource === undefined) return { ok: false, message: `unknown resource: ${request.uri}` }
102
+ return { ok: true, resource: await resource.read() }
103
+ }
104
+ const prompt = extractBackendPrompts(await backend()).find((p) => p.name === request.name)
105
+ if (prompt === undefined) return { ok: false, message: `unknown prompt: ${request.name}` }
106
+ return { ok: true, messages: await prompt.handler(request.args) }
107
+ } catch (error) {
108
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
109
+ }
110
+ }
111
+
112
+ const isArgs = (value: unknown): value is Record<string, unknown> =>
113
+ typeof value === "object" && value !== null && !Array.isArray(value)
114
+
115
+ function parseRequest(text: string): { token: string; request: ProjectChildRequest } | undefined {
116
+ let value: unknown
117
+ try {
118
+ value = JSON.parse(text)
119
+ } catch {
120
+ return undefined
121
+ }
122
+ if (!isArgs(value)) return undefined
123
+ const { token, op, name, uri, args } = value
124
+ if (typeof token !== "string" || !/^[0-9a-f-]{16,64}$/.test(token)) return undefined
125
+ if (op === "monorepo" || op === "surface") return { token, request: { op } }
126
+ if (op === "resource" && typeof uri === "string") return { token, request: { op, uri } }
127
+ if ((op === "tool" || op === "prompt") && typeof name === "string" && isArgs(args)) {
128
+ return { token, request: { op, name, args } }
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ /**
134
+ * Serve one request: read it from `input`, answer it and write each message through `write`,
135
+ * prefixed with the request's token. Returns false (writing nothing) for a malformed request.
136
+ */
137
+ export async function serveProjectChild(
138
+ cwd: string,
139
+ input: ReadableStream<Uint8Array>,
140
+ write: (line: string) => Promise<void>,
141
+ ): Promise<boolean> {
142
+ const text = await readBoundedStream(input, CHILD_INPUT_MAX_BYTES)
143
+ const parsed = text.truncated ? undefined : parseRequest(text.text)
144
+ if (parsed === undefined) return false
145
+ const send = (message: ProjectChildMessage): Promise<void> =>
146
+ write(`${parsed.token} ${JSON.stringify(message)}\n`)
147
+ const answer = await answerProjectRequest(cwd, parsed.request, (progress, total) => {
148
+ void send(
149
+ total === undefined ? { type: "progress", progress } : { type: "progress", progress, total },
150
+ )
151
+ })
152
+ let line: string
153
+ try {
154
+ line = JSON.stringify({ type: "answer", answer } satisfies ProjectChildMessage)
155
+ } catch {
156
+ line = JSON.stringify({
157
+ type: "answer",
158
+ answer: { ok: false, message: "the answer is not serializable" },
159
+ } satisfies ProjectChildMessage)
160
+ }
161
+ await write(`${parsed.token} ${line}\n`)
162
+ return true
163
+ }
164
+
165
+ if (import.meta.main) {
166
+ // Bound before project code loads, so nothing it patches can reach the protocol channel.
167
+ const stdout = process.stdout.write.bind(process.stdout)
168
+ const toStderr = (...args: unknown[]): void => {
169
+ process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`)
170
+ }
171
+ console.log = toStderr
172
+ console.info = toStderr
173
+ console.debug = toStderr
174
+ const served = await serveProjectChild(
175
+ process.argv[2] ?? process.cwd(),
176
+ Bun.stdin.stream(),
177
+ (line) => new Promise((done) => stdout(line, () => done())),
178
+ )
179
+ if (!served) process.stderr.write("nifra mcp: invalid project request\n")
180
+ // Project code may leave a handle open (a pool, an interval); the answer is written, so stop here.
181
+ process.exit(served ? 0 : 2)
182
+ }
@@ -5,7 +5,8 @@
5
5
  * about stdio, HTTP, root trust, or project loading, so the reflection seam is independently testable.
6
6
  */
7
7
 
8
- import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
8
+ import { reflectedRoutesFromEvidence, snapshotProjectEvidence } from "@nifrajs/core/evidence"
9
+ import type { ReflectedRoute } from "@nifrajs/core/reflection"
9
10
  import type {
10
11
  McpPrompt,
11
12
  McpPromptMessage,
@@ -24,7 +25,7 @@ type ToolBackend = {
24
25
  export function extractBackendTools(backend: unknown): McpTool[] {
25
26
  const b = backend as ToolBackend | null
26
27
  if (!b || typeof b.routes !== "function") return []
27
- const routes = reflectRoutes(b)
28
+ const routes = reflectedRoutesFromEvidence(snapshotProjectEvidence(b))
28
29
 
29
30
  return routes
30
31
  .filter(
package/src/mcp-render.ts CHANGED
@@ -10,11 +10,18 @@
10
10
  * `{ error }` so the agent gets the actionable message.
11
11
  */
12
12
 
13
- import { inProcessClient } from "@nifrajs/client"
14
13
  import { type AppLike, runApp } from "@nifrajs/runner"
15
14
  import { createWebApp, type RenderAdapter } from "@nifrajs/web"
16
15
  import { discoverRoutes } from "@nifrajs/web/fs"
17
16
  import { loadApp, resolvePlugins } from "./load.ts"
17
+ import {
18
+ CHILD_INPUT_MAX_BYTES,
19
+ CHILD_OUTPUT_MAX_BYTES,
20
+ readBoundedLines,
21
+ readBoundedStream,
22
+ serializeBoundedJson,
23
+ } from "./mcp-io.ts"
24
+ import { frameworkWebAppOptions } from "./web-app-options.ts"
18
25
 
19
26
  const errString = (err: unknown): string =>
20
27
  err instanceof Error ? `${err.name}: ${err.message}` : String(err)
@@ -47,7 +54,7 @@ async function buildWebApp(cwd: string): Promise<{ app: AppLike } | { error: str
47
54
  adapter: app.framework.adapter as RenderAdapter,
48
55
  manifest: discoverRoutes(app.routesDir),
49
56
  clientEntry: "/_nifra-render-only.js", // placeholder; SSR HTML does not need the built client
50
- ...(app.backend !== undefined ? { api: inProcessClient(app.backend as never) } : {}),
57
+ ...frameworkWebAppOptions(app.framework, app.backend),
51
58
  }) as unknown as AppLike
52
59
  return { app: webApp }
53
60
  } catch (err) {
@@ -89,6 +96,12 @@ function redirectConsoleToStderr(): void {
89
96
  console.error = (...args: unknown[]) => write("error", args)
90
97
  }
91
98
 
99
+ function safeResponseId(value: unknown): number | string | null {
100
+ if (typeof value === "number" && Number.isSafeInteger(value)) return value
101
+ if (typeof value === "string" && value.length <= 128) return value
102
+ return null
103
+ }
104
+
92
105
  /**
93
106
  * Warm-worker loop (`--worker`): build the web app ONCE, then SSR each `{ id, input: { requests } }` line
94
107
  * against that hot app and reply `{ id, output }`. The parent (`mcp.ts`'s warm-render handler) spawns this,
@@ -100,43 +113,51 @@ async function runWorker(cwd: string): Promise<void> {
100
113
  // Build once; the same `{ error }` is returned for every request if the build failed, so an agent sees
101
114
  // the actionable message and the parent restarts the worker on the next file change.
102
115
  const built = await buildWebApp(cwd)
103
- const decoder = new TextDecoder()
104
- let buffer = ""
105
116
  const send = (id: unknown, output: unknown): void => {
106
- process.stdout.write(`${JSON.stringify({ id, output })}\n`)
117
+ const responseId = safeResponseId(id)
118
+ const encoded = serializeBoundedJson({ id: responseId, output }, CHILD_OUTPUT_MAX_BYTES - 1)
119
+ process.stdout.write(
120
+ `${encoded ?? JSON.stringify({ id: responseId, output: { error: "worker output exceeded the size limit" } })}\n`,
121
+ )
107
122
  }
108
- for await (const chunk of Bun.stdin.stream()) {
109
- buffer += decoder.decode(chunk as Uint8Array, { stream: true })
110
- let nl = buffer.indexOf("\n")
111
- while (nl !== -1) {
112
- const line = buffer.slice(0, nl).trim()
113
- buffer = buffer.slice(nl + 1)
114
- nl = buffer.indexOf("\n")
115
- if (line === "") continue
123
+ for await (const item of readBoundedLines(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)) {
124
+ if (item.kind === "too-large") {
125
+ send(null, { error: `worker input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` })
126
+ continue
127
+ }
128
+ const line = item.text.trim()
129
+ if (line === "") continue
116
130
 
117
- let message: RenderWorkerMessage
118
- try {
119
- message = JSON.parse(line) as RenderWorkerMessage
120
- } catch {
121
- send(null, { error: "invalid worker input: expected JSON line" })
122
- continue
123
- }
124
- const requests = message.input?.requests
125
- if (!Array.isArray(requests)) {
126
- send(message.id, { error: "expected { requests: [...] }" })
127
- continue
128
- }
129
- if ("error" in built) {
130
- send(message.id, built)
131
- continue
132
- }
133
- try {
134
- send(message.id, {
135
- results: await runApp(built.app, requests as Parameters<typeof runApp>[1]),
136
- })
137
- } catch (err) {
138
- send(message.id, { error: errString(err) })
139
- }
131
+ let message: unknown
132
+ try {
133
+ message = JSON.parse(line) as unknown
134
+ } catch {
135
+ send(null, { error: "invalid worker input: expected JSON line" })
136
+ continue
137
+ }
138
+ const record =
139
+ message !== null && typeof message === "object" && !Array.isArray(message)
140
+ ? (message as RenderWorkerMessage)
141
+ : undefined
142
+ const input =
143
+ record?.input !== null && typeof record?.input === "object" && !Array.isArray(record.input)
144
+ ? record.input
145
+ : undefined
146
+ const requests = input?.requests
147
+ if (!Array.isArray(requests)) {
148
+ send(record?.id, { error: "expected { requests: [...] }" })
149
+ continue
150
+ }
151
+ if ("error" in built) {
152
+ send(record?.id, built)
153
+ continue
154
+ }
155
+ try {
156
+ send(record?.id, {
157
+ results: await runApp(built.app, requests as Parameters<typeof runApp>[1]),
158
+ })
159
+ } catch (err) {
160
+ send(record?.id, { error: errString(err) })
140
161
  }
141
162
  }
142
163
  }
@@ -163,13 +184,22 @@ if (import.meta.main) {
163
184
  process.exit(0)
164
185
  }
165
186
  let output: unknown
166
- try {
167
- const { requests } = JSON.parse(await Bun.stdin.text()) as { requests: unknown }
168
- output = await renderPages(cwd, requests)
169
- } catch {
170
- output = { error: "invalid input: expected JSON { requests: [...] }" }
187
+ const input = await readBoundedStream(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)
188
+ if (input.truncated) {
189
+ output = { error: `input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` }
190
+ } else {
191
+ try {
192
+ const { requests } = JSON.parse(input.text) as { requests: unknown }
193
+ output = await renderPages(cwd, requests)
194
+ } catch {
195
+ output = { error: "invalid input: expected JSON { requests: [...] }" }
196
+ }
171
197
  }
172
- await Bun.write(Bun.stdout, JSON.stringify(output, null, 2))
198
+ await Bun.write(
199
+ Bun.stdout,
200
+ serializeBoundedJson(output, CHILD_OUTPUT_MAX_BYTES, 2) ??
201
+ JSON.stringify({ error: `output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes` }),
202
+ )
173
203
  // Exit explicitly, exactly as the `--worker` branch above already does.
174
204
  //
175
205
  // Loading the app runs its module side effects, and a real app opens things: a database pool, a
package/src/mcp-root.ts CHANGED
@@ -5,9 +5,12 @@
5
5
  * 1. An explicit `nifra mcp <dir>` argument is taken literally (human configuration wins).
6
6
  * 2. A cwd guess walks UP to the nearest nifra marker (a `package.json` depending on `@nifrajs/*`,
7
7
  * or a `nifra.config.ts` monorepo root), so running from a subdirectory still finds the project.
8
+ * When nothing above is a project but the spawn directory is a package-manager workspace root
9
+ * (`package.json` `workspaces`) with exactly one nifra member, the server adopts that member.
8
10
  * 3. The client's MCP `roots` (its workspace folders) are requested after the handshake. When the
9
11
  * guess found no project - or found one DISJOINT from every workspace root - and exactly one
10
- * workspace root is a nifra project, the server adopts it.
12
+ * workspace root (or one member of a workspace-root folder) is a nifra project, the server
13
+ * adopts it.
11
14
  * 4. What cannot be resolved fails CLOSED: project-scoped tools refuse with a remediation message
12
15
  * instead of describing whatever directory the server happened to start in.
13
16
  *
@@ -15,12 +18,13 @@
15
18
  */
16
19
 
17
20
  import { readFile } from "node:fs/promises"
18
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
21
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
19
22
  import { fileURLToPath } from "node:url"
20
23
 
21
24
  /** How the current root was chosen. `arg` = explicit `nifra mcp <dir>`; `cwd` = the spawn directory;
22
- * `parent` = a marker found walking up from cwd; `client-root` = adopted from the client's MCP roots. */
23
- export type McpRootSource = "arg" | "cwd" | "parent" | "client-root"
25
+ * `parent` = a marker found walking up from cwd; `workspace` = the one nifra member of the spawn
26
+ * directory's package-manager workspaces; `client-root` = adopted from the client's MCP roots. */
27
+ export type McpRootSource = "arg" | "cwd" | "parent" | "workspace" | "client-root"
24
28
 
25
29
  export interface McpRootState {
26
30
  /** Absolute directory every project-scoped tool operates on. */
@@ -31,8 +35,18 @@ export interface McpRootState {
31
35
  /** The client's workspace roots (absolute paths), or `null` until (unless) the client answers
32
36
  * `roots/list`. `null` disables mismatch detection - no data is not a mismatch. */
33
37
  readonly clientRoots: readonly string[] | null
38
+ /** Nifra members of the spawn directory's workspaces when there were several to choose from - the
39
+ * remediation list, since adopting one of them would be a guess. */
40
+ readonly workspaceCandidates?: readonly string[]
34
41
  }
35
42
 
43
+ const MAX_CLIENT_ROOTS = 64
44
+ const MAX_ROOT_URI_LENGTH = 16_384
45
+ const MAX_ROOT_PATH_LENGTH = 4_096
46
+ /** Workspace members probed per workspace root. A monorepo larger than this is pointed at explicitly
47
+ * (`nifra mcp <dir>`) rather than scanned on every server start. */
48
+ const MAX_WORKSPACE_MEMBERS = 256
49
+
36
50
  /** A directory is a nifra project when its `package.json` depends on any `@nifrajs/*` package, or it
37
51
  * is a `nifra.config.ts` monorepo root. Backend-only projects (no web config, no `routes/`) count -
38
52
  * the marker is the dependency, not the app shape. */
@@ -65,8 +79,56 @@ export async function findNifraRoot(start: string): Promise<string | null> {
65
79
  }
66
80
  }
67
81
 
82
+ /** The `workspaces` globs a `package.json` declares (the array form, or Yarn's `{ packages }`), or
83
+ * `null` when the directory is not a workspace root. Negated patterns are dropped: they only exclude. */
84
+ async function workspacePatterns(dir: string): Promise<readonly string[] | null> {
85
+ let field: unknown
86
+ try {
87
+ field = (
88
+ JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as Record<string, unknown>
89
+ ).workspaces
90
+ } catch {
91
+ return null
92
+ }
93
+ const list = Array.isArray(field)
94
+ ? field
95
+ : typeof field === "object" && field !== null
96
+ ? (field as { packages?: unknown }).packages
97
+ : undefined
98
+ if (!Array.isArray(list)) return null
99
+ return list.filter(
100
+ (pattern): pattern is string =>
101
+ typeof pattern === "string" && pattern !== "" && !pattern.startsWith("!"),
102
+ )
103
+ }
104
+
105
+ /**
106
+ * The nifra projects among `dir`'s package-manager workspace members, in declaration order. Empty when
107
+ * `dir` declares no `workspaces`. A member is a directory a pattern matches that holds a `package.json`;
108
+ * `node_modules` is never a member.
109
+ */
110
+ export async function workspaceProjects(dir: string): Promise<string[]> {
111
+ const patterns = await workspacePatterns(dir)
112
+ if (patterns === null) return []
113
+ const members = new Set<string>()
114
+ let probed = 0
115
+ for (const pattern of patterns) {
116
+ const glob = new Bun.Glob(`${pattern.replace(/\/+$/, "")}/package.json`)
117
+ for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) {
118
+ if (match.split(/[\\/]/).includes("node_modules")) continue
119
+ if (++probed > MAX_WORKSPACE_MEMBERS) break
120
+ members.add(dirname(join(dir, match)))
121
+ }
122
+ if (probed > MAX_WORKSPACE_MEMBERS) break
123
+ }
124
+ const projects: string[] = []
125
+ for (const member of members) if (await isNifraProjectDir(member)) projects.push(member)
126
+ return projects
127
+ }
128
+
68
129
  /** Initial root state. An explicit dir is taken literally (no walk-up - the human named it); a cwd
69
- * guess walks up so a subdirectory spawn still lands on the project. */
130
+ * guess walks up so a subdirectory spawn still lands on the project, then tries the spawn directory's
131
+ * workspace members so a monorepo root finds its one nifra app. */
70
132
  export async function resolveRootState(
71
133
  requested: string,
72
134
  explicit: boolean,
@@ -88,7 +150,17 @@ export async function resolveRootState(
88
150
  clientRoots: null,
89
151
  }
90
152
  }
91
- return { root: requested, source: "cwd", isProject: false, clientRoots: null }
153
+ const members = await workspaceProjects(requested)
154
+ if (members.length === 1) {
155
+ return { root: members[0] as string, source: "workspace", isProject: true, clientRoots: null }
156
+ }
157
+ return {
158
+ root: requested,
159
+ source: "cwd",
160
+ isProject: false,
161
+ clientRoots: null,
162
+ ...(members.length > 1 ? { workspaceCandidates: members } : {}),
163
+ }
92
164
  }
93
165
 
94
166
  /** `file://` roots from a `roots/list` result, as absolute paths. Non-file and malformed URIs are
@@ -98,9 +170,12 @@ export function pathsFromRootsResult(result: unknown): string[] {
98
170
  const roots = (result as { roots?: unknown }).roots
99
171
  if (!Array.isArray(roots)) return []
100
172
  const paths: string[] = []
173
+ const seen = new Set<string>()
101
174
  for (const entry of roots) {
175
+ if (paths.length >= MAX_CLIENT_ROOTS) break
102
176
  const uri = (entry as { uri?: unknown })?.uri
103
- if (typeof uri !== "string" || !uri.startsWith("file://")) continue
177
+ if (typeof uri !== "string" || uri.length > MAX_ROOT_URI_LENGTH || !uri.startsWith("file://"))
178
+ continue
104
179
  try {
105
180
  const parsed = new URL(uri)
106
181
  // A remote file host is a UNC path on Windows, not a local workspace root. MCP roots are
@@ -111,7 +186,11 @@ export function pathsFromRootsResult(result: unknown): string[] {
111
186
  (parsed.hostname !== "" && parsed.hostname !== "localhost")
112
187
  )
113
188
  continue
114
- paths.push(fileURLToPath(parsed))
189
+ const path = fileURLToPath(parsed)
190
+ if (path.length === 0 || path.length > MAX_ROOT_PATH_LENGTH || path.includes("\0")) continue
191
+ if (seen.has(path)) continue
192
+ seen.add(path)
193
+ paths.push(path)
115
194
  } catch {
116
195
  // Malformed URI - skip.
117
196
  }
@@ -140,9 +219,9 @@ export function rootMismatch(state: McpRootState): boolean {
140
219
  /**
141
220
  * Fold the client's workspace roots into the state, adopting one of them when the current root is
142
221
  * wrong and the correction is unambiguous: the root was GUESSED (never an explicit `nifra mcp <dir>`),
143
- * it is not a project - or is one disjoint from the whole workspace - and exactly ONE workspace root
144
- * is a nifra project. Ambiguity (zero or several candidates) adopts nothing; the guard's remediation
145
- * message lists the candidates instead.
222
+ * it is not a project - or is one disjoint from the whole workspace - and exactly ONE candidate exists
223
+ * (see {@link nifraCandidates}). Ambiguity (zero or several candidates) adopts nothing; the guard's
224
+ * remediation message lists the candidates instead.
146
225
  */
147
226
  export async function applyClientRoots(
148
227
  state: McpRootState,
@@ -151,8 +230,7 @@ export async function applyClientRoots(
151
230
  const next: McpRootState = { ...state, clientRoots: paths }
152
231
  if (state.source === "arg") return next
153
232
  if (next.isProject && !rootMismatch(next)) return next
154
- const candidates: string[] = []
155
- for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
233
+ const candidates = await nifraCandidates(paths)
156
234
  if (candidates.length !== 1) return next
157
235
  return {
158
236
  root: candidates[0] as string,
@@ -162,11 +240,15 @@ export async function applyClientRoots(
162
240
  }
163
241
  }
164
242
 
165
- /** The nifra project roots among the client's workspace roots - the remediation list when adoption
166
- * was ambiguous. */
243
+ /** The nifra projects the client's workspace roots name: each root that is a project itself, and
244
+ * otherwise the nifra members of a root that is a package-manager workspace. The remediation list
245
+ * when adoption was ambiguous. */
167
246
  export async function nifraCandidates(paths: readonly string[]): Promise<string[]> {
168
247
  const candidates: string[] = []
169
- for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
248
+ for (const path of paths) {
249
+ if (await isNifraProjectDir(path)) candidates.push(path)
250
+ else for (const member of await workspaceProjects(path)) candidates.push(member)
251
+ }
170
252
  return candidates
171
253
  }
172
254
 
@@ -187,10 +269,15 @@ export async function rootVerdict(state: McpRootState): Promise<McpRootVerdict>
187
269
  ? ` Client workspace roots: ${state.clientRoots.join(", ")}.`
188
270
  : ""
189
271
  if (!state.isProject) {
190
- const candidates = state.clientRoots !== null ? await nifraCandidates(state.clientRoots) : []
272
+ const candidates = [
273
+ ...new Set([
274
+ ...(state.workspaceCandidates ?? []),
275
+ ...(state.clientRoots !== null ? await nifraCandidates(state.clientRoots) : []),
276
+ ]),
277
+ ]
191
278
  const hint =
192
279
  candidates.length > 1
193
- ? ` Several workspace roots are nifra projects (${candidates.join(", ")}) - pass one: \`nifra mcp <dir>\`.`
280
+ ? ` Several workspace folders are nifra projects (${candidates.join(", ")}) - pass one: \`nifra mcp <dir>\`.`
194
281
  : ""
195
282
  return {
196
283
  blocked:
@@ -239,7 +326,8 @@ export interface ToolingDrift {
239
326
  export const driftNote = (drift: ToolingDrift): string =>
240
327
  `WARNING: this server runs nifra CLI ${drift.cli}, but the project installs ${drift.package} ${drift.project}. ` +
241
328
  "Types, checks, and docs may describe a different version than your code builds with. " +
242
- "Fix: run the project's own CLI (`bunx --bun nifra mcp` from the project directory, or point the client at ./node_modules/.bin/nifra)."
329
+ `Fix: re-pin the project's MCP launch to the nifra it installs with \`${syncMcpCommand(drift.cli)}\` (run in the project directory), then restart the agent. ` +
330
+ "If this server is not launched from the project's .mcp.json, run the project's own CLI instead (`bunx --bun nifra mcp` from the project directory, or point the client at ./node_modules/.bin/nifra)."
243
331
 
244
332
  /** Feature-level agreement. Patch drift is not reported: it never changes the surface being described. */
245
333
  const featureVersion = (version: string): string => version.split(".").slice(0, 2).join(".")
@@ -274,16 +362,34 @@ export async function detectToolingDrift(
274
362
  root: string,
275
363
  cliVersion: string,
276
364
  ): Promise<ToolingDrift | undefined> {
365
+ const installed = await installedNifraVersion(root)
366
+ if (installed === undefined) return undefined
367
+ return featureVersion(installed.version) === featureVersion(cliVersion)
368
+ ? undefined
369
+ : { cli: cliVersion, project: installed.version, package: installed.package }
370
+ }
371
+
372
+ /**
373
+ * The nifra the project builds with: its `@nifrajs/cli` when installed, else its `@nifrajs/core`
374
+ * (`fixed` versioning keeps the two in lockstep, so either names the CLI that matches the project).
375
+ * The version drift detection compares against and the version `init-agents --sync-mcp` pins to.
376
+ */
377
+ export async function installedNifraVersion(
378
+ root: string,
379
+ ): Promise<{ readonly package: string; readonly version: string } | undefined> {
277
380
  for (const name of ["@nifrajs/cli", "@nifrajs/core"]) {
278
- const project = await installedVersion(root, name)
279
- if (project === undefined) continue
280
- return featureVersion(project) === featureVersion(cliVersion)
281
- ? undefined
282
- : { cli: cliVersion, project, package: name }
381
+ const version = await installedVersion(root, name)
382
+ if (version !== undefined) return { package: name, version }
283
383
  }
284
384
  return undefined
285
385
  }
286
386
 
387
+ /** The command that re-pins a project's MCP launch to the nifra it installs. Named with the running
388
+ * CLI's own exact version: the binary printing this is the one known to have `--sync-mcp`, and it pins
389
+ * to the project's version, not its own. */
390
+ export const syncMcpCommand = (cliVersion: string): string =>
391
+ `bunx @nifrajs/cli@${cliVersion} init-agents --sync-mcp`
392
+
287
393
  /** Tool names served from the bundled corpus; they do not depend on the project root. */
288
394
  const PROJECT_FREE_TOOLS = new Set(["nifra_docs", "nifra_example", "nifra_types", "nifra_learn"])
289
395