@bevel-software/platform-core-backend 0.7.5 → 0.9.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 (282) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +8 -1
  4. package/dist/core/core-ports.d.ts.map +1 -1
  5. package/dist/core/core-ports.js.map +1 -1
  6. package/dist/core/create-core-server.d.ts.map +1 -1
  7. package/dist/core/create-core-server.js +75 -18
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +12 -5
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +69 -36
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/modules/access/access-control.interface.d.ts +8 -7
  14. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts +1 -1
  16. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.js +6 -6
  18. package/dist/modules/access/access-control.service.js.map +1 -1
  19. package/dist/modules/access/access-declarations.d.ts +5 -5
  20. package/dist/modules/access/access-declarations.js +3 -3
  21. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +3 -3
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.js +4 -4
  26. package/dist/modules/access/access-splice.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts +3 -1
  28. package/dist/modules/access/access.routes.d.ts.map +1 -1
  29. package/dist/modules/access/access.routes.js +22 -20
  30. package/dist/modules/access/access.routes.js.map +1 -1
  31. package/dist/modules/access/creator-access.d.ts +2 -2
  32. package/dist/modules/access/creator-access.js +5 -5
  33. package/dist/modules/access/creator-access.js.map +1 -1
  34. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  35. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  36. package/dist/modules/access/render-roles-yaml.js +56 -0
  37. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  38. package/dist/modules/access/roles-admin.service.d.ts +33 -5
  39. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  40. package/dist/modules/access/roles-admin.service.js +44 -19
  41. package/dist/modules/access/roles-admin.service.js.map +1 -1
  42. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  43. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  44. package/dist/modules/code-mode/code-mode-names.js +5 -27
  45. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  46. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  47. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  48. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  49. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  50. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  51. package/dist/modules/mcp/mcp.service.js +33 -395
  52. package/dist/modules/mcp/mcp.service.js.map +1 -1
  53. package/dist/modules/plugins/index.d.ts +7 -0
  54. package/dist/modules/plugins/index.d.ts.map +1 -0
  55. package/dist/modules/plugins/index.js +6 -0
  56. package/dist/modules/plugins/index.js.map +1 -0
  57. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  58. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  59. package/dist/modules/plugins/join-proposals.js +67 -0
  60. package/dist/modules/plugins/join-proposals.js.map +1 -0
  61. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  62. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  63. package/dist/modules/plugins/join-requests.service.js +135 -0
  64. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  65. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  66. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  68. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  69. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  70. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.contract.js +36 -0
  72. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  73. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  74. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  75. package/dist/modules/plugins/plugins.routes.js +379 -0
  76. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  77. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  78. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  79. package/dist/modules/plugins/plugins.service.js +172 -0
  80. package/dist/modules/plugins/plugins.service.js.map +1 -0
  81. package/dist/modules/settings/setup.routes.d.ts +10 -1
  82. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  83. package/dist/modules/settings/setup.routes.js +111 -6
  84. package/dist/modules/settings/setup.routes.js.map +1 -1
  85. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  86. package/dist/modules/skills/pending-skills.service.js +7 -7
  87. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  88. package/dist/modules/skills/skills.contract.d.ts +4 -4
  89. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  90. package/dist/modules/skills/skills.contract.js +1 -1
  91. package/dist/modules/skills/skills.service.js +5 -5
  92. package/dist/modules/skills/skills.service.js.map +1 -1
  93. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  94. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  95. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  96. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  97. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  98. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  99. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  100. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  101. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  102. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  103. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  104. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  105. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  106. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  107. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  108. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  109. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  110. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  111. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  112. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  113. package/dist/modules/workflow/git/git.service.js +2 -2
  114. package/dist/modules/workflow/git/git.service.js.map +1 -1
  115. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  116. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  117. package/dist/modules/workspace/kb-seed.service.js +43 -10
  118. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  119. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  120. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  121. package/dist/modules/workspace/plugins-migration.js +379 -0
  122. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  123. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  124. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  125. package/dist/modules/workspace/startup/kb-git.js +86 -0
  126. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  127. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  128. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  129. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  130. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  131. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  132. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  133. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  134. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  135. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  136. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  137. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  138. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  139. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  140. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  141. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  142. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  143. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  144. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  145. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  146. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  147. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  148. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  149. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  150. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  151. package/dist/modules/workspace/workspace.routes.js +3 -3
  152. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  153. package/dist/modules/workspace/workspace.service.d.ts +0 -9
  154. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  155. package/dist/modules/workspace/workspace.service.js +2 -30
  156. package/dist/modules/workspace/workspace.service.js.map +1 -1
  157. package/dist/shared/kb-layout.test.js +3 -3
  158. package/dist/shared/kb-layout.test.js.map +1 -1
  159. package/dist/shared/utcp-namespace.d.ts +6 -27
  160. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  161. package/dist/shared/utcp-namespace.js +6 -63
  162. package/dist/shared/utcp-namespace.js.map +1 -1
  163. package/dist/shared/variable-refs.d.ts +42 -0
  164. package/dist/shared/variable-refs.d.ts.map +1 -0
  165. package/dist/shared/variable-refs.js +60 -0
  166. package/dist/shared/variable-refs.js.map +1 -0
  167. package/kb-template/.bevelignore +1 -1
  168. package/kb-template/AGENTS.md +88 -35
  169. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  170. package/kb-template/access.md +36 -36
  171. package/kb-template/gitignore.template +16 -0
  172. package/migrations/meta/0000_snapshot.json +1479 -1479
  173. package/package.json +5 -4
  174. package/src/assets.ts +25 -25
  175. package/src/core/core-ports.ts +113 -106
  176. package/src/core/create-core-server.ts +85 -20
  177. package/src/core/create-core-services.ts +83 -42
  178. package/src/index.ts +69 -69
  179. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  180. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  181. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  182. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  183. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  184. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  185. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  186. package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
  187. package/src/modules/access/access-control.interface.ts +8 -7
  188. package/src/modules/access/access-control.service.ts +7 -7
  189. package/src/modules/access/access-declarations.ts +5 -5
  190. package/src/modules/access/access-mutation.service.ts +3 -3
  191. package/src/modules/access/access-splice.ts +4 -4
  192. package/src/modules/access/access.routes.ts +23 -20
  193. package/src/modules/access/creator-access.ts +5 -5
  194. package/src/modules/access/render-roles-yaml.ts +65 -0
  195. package/src/modules/access/roles-admin.service.ts +45 -16
  196. package/src/modules/admin/admin-access.routes.ts +29 -29
  197. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  198. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  199. package/src/modules/auth/rate-limit.ts +45 -45
  200. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  201. package/src/modules/code-mode/code-mode-names.ts +10 -36
  202. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  203. package/src/modules/database/connection.ts +15 -15
  204. package/src/modules/database/schema.ts +11 -11
  205. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  206. package/src/modules/mcp/mcp.service.ts +57 -435
  207. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  208. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  209. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  210. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  211. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  212. package/src/modules/plugins/index.ts +17 -0
  213. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  214. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  215. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  216. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  217. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  218. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  219. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  220. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  221. package/src/modules/settings/setup.routes.ts +112 -5
  222. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  223. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  224. package/src/modules/skills/pending-skills.service.ts +7 -7
  225. package/src/modules/skills/skills.contract.ts +4 -4
  226. package/src/modules/skills/skills.service.ts +5 -5
  227. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  228. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  229. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  230. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  231. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  232. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  233. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  234. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  235. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  236. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  237. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  238. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  239. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  240. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  241. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  242. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  243. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  244. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  245. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  246. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  247. package/src/modules/workflow/git/git.service.ts +2 -2
  248. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  249. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  250. package/src/modules/workflow/workflow-hooks.ts +101 -101
  251. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  252. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  253. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  254. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  255. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  256. package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
  257. package/src/modules/workspace/bevel-ignore.ts +66 -66
  258. package/src/modules/workspace/session-sink.ts +25 -25
  259. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  260. package/src/modules/workspace/startup/kb-git.ts +94 -0
  261. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  262. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  263. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  264. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +561 -0
  265. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  266. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  267. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  268. package/src/modules/workspace/workspace.routes.ts +3 -3
  269. package/src/modules/workspace/workspace.service.ts +2 -35
  270. package/src/modules/workspace/workspace.tools.ts +922 -922
  271. package/src/shared/__tests__/join-request.test.ts +13 -13
  272. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  273. package/src/shared/kb-layout.test.ts +3 -3
  274. package/src/shared/utcp-namespace.ts +10 -68
  275. package/src/shared/variable-refs.ts +64 -0
  276. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  277. package/src/modules/groups/index.ts +0 -17
  278. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
  279. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  280. package/src/modules/workspace/kb-seed.service.ts +0 -555
  281. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  282. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -46,7 +46,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
46
46
 
47
47
  beforeEach(async () => {
48
48
  root = await mkdtemp(join(tmpdir(), 'tools-oauth-'));
49
- const toolsDir = join(root, wsId, KB_DIR, 'Groups');
49
+ const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
50
50
  await mkdir(toolsDir, { recursive: true });
51
51
  await writeFile(join(toolsDir, 'notion.tool'), BARE_MCP_TOOL);
52
52
  await writeFile(join(toolsDir, 'jira.tool'), KEYED_MCP_TOOL);
@@ -109,7 +109,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
109
109
  });
110
110
 
111
111
  test('referenced-but-undeclared ${VAR}s are auto-surfaced as admin keys (any tool type)', async () => {
112
- const toolsDir = join(root, wsId, KB_DIR, 'Groups');
112
+ const toolsDir = join(root, wsId, KB_DIR, 'Plugins');
113
113
  await writeFile(
114
114
  join(toolsDir, 'billing.tool'),
115
115
  JSON.stringify({
@@ -68,7 +68,7 @@ describe('ToolManualService', () => {
68
68
 
69
69
  beforeEach(async () => {
70
70
  root = await mkdtemp(join(tmpdir(), 'tools-'));
71
- const tools = join(root, wsId, KB_DIR, 'Groups');
71
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
72
72
  await mkdir(tools, { recursive: true });
73
73
  await writeFile(join(tools, 'weather.tool'), INLINE_TOOL);
74
74
  await writeFile(join(tools, 'billing.tool'), HTTP_TOOL);
@@ -88,13 +88,13 @@ describe('ToolManualService', () => {
88
88
  });
89
89
 
90
90
  test('listAllSummaries returns every manual regardless of caller access', async () => {
91
- // The group index counts a group's tools for people who cannot read them,
91
+ // The plugin index counts a plugin's tools for people who cannot read them,
92
92
  // so this surface must ignore the ACL that `listAccessible` applies.
93
93
  const service = svc(denyBilling);
94
94
  expect((await service.listAccessible('user@x.eu')).map((m) => m.name)).toEqual(['weather']);
95
95
  const all = await service.listAllSummaries();
96
96
  expect(all.map((m) => m.name).sort()).toEqual(['billing', 'weather']);
97
- expect(all.map((m) => m.path).sort()).toEqual(['Groups/billing.tool', 'Groups/weather.tool']);
97
+ expect(all.map((m) => m.path).sort()).toEqual(['Plugins/billing.tool', 'Plugins/weather.tool']);
98
98
  });
99
99
 
100
100
  test('builds an inline manual as an http sub-manual call-template', async () => {
@@ -123,7 +123,7 @@ describe('ToolManualService', () => {
123
123
 
124
124
  test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
125
125
  root = await mkdtemp(join(tmpdir(), 'tools2-'));
126
- const tools = join(root, wsId, KB_DIR, 'Groups');
126
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
127
127
  await mkdir(tools, { recursive: true });
128
128
  await writeFile(join(tools, 'my_cool-tool.tool'), JSON.stringify({ type: 'http', url: 'https://x.example.com/m' }));
129
129
  const list = await svc().listAccessible('user@x.eu');
@@ -133,7 +133,7 @@ describe('ToolManualService', () => {
133
133
 
134
134
  test('refuses a `.tool` whose manual name collides (no silent suffix)', async () => {
135
135
  root = await mkdtemp(join(tmpdir(), 'tools3-'));
136
- const tools = join(root, wsId, KB_DIR, 'Groups');
136
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
137
137
  await mkdir(tools, { recursive: true });
138
138
  // Both names normalize to `stripe`. The scanner keeps the first (sorted path
139
139
  // order) and REFUSES the duplicate rather than renaming it `stripe2` — a
@@ -144,12 +144,12 @@ describe('ToolManualService', () => {
144
144
  await writeFile(join(tools, 'b.tool'), JSON.stringify({ name: 'stripe!', type: 'http', url: 'https://b.example.com/m' }));
145
145
  const list = await svc().listAccessible('user@x.eu');
146
146
  expect(list.map((m) => m.name)).toEqual(['stripe']);
147
- expect(list[0].path).toBe('Groups/a.tool');
147
+ expect(list[0].path).toBe('Plugins/a.tool');
148
148
  });
149
149
 
150
150
  test('parses `variables` scopes (default admin, explicit user/admin)', async () => {
151
151
  root = await mkdtemp(join(tmpdir(), 'toolsv-'));
152
- const tools = join(root, wsId, KB_DIR, 'Groups');
152
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
153
153
  await mkdir(tools, { recursive: true });
154
154
  await writeFile(
155
155
  join(tools, 'weather.tool'),
@@ -174,7 +174,7 @@ describe('ToolManualService', () => {
174
174
 
175
175
  test('skips a `.tool` with a malformed `variables` entry (never silently mis-scoped)', async () => {
176
176
  root = await mkdtemp(join(tmpdir(), 'toolsvbad-'));
177
- const tools = join(root, wsId, KB_DIR, 'Groups');
177
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
178
178
  await mkdir(tools, { recursive: true });
179
179
  await writeFile(
180
180
  join(tools, 'weather.tool'),
@@ -185,7 +185,7 @@ describe('ToolManualService', () => {
185
185
 
186
186
  test('scopeOfVariable: declared scope wins; undeclared/unknown default to admin', async () => {
187
187
  root = await mkdtemp(join(tmpdir(), 'toolsscope-'));
188
- const tools = join(root, wsId, KB_DIR, 'Groups');
188
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
189
189
  await mkdir(tools, { recursive: true });
190
190
  await writeFile(
191
191
  join(tools, 'weather.tool'),
@@ -221,7 +221,7 @@ describe('ToolManualService', () => {
221
221
  });
222
222
  const writeOAuthTool = async (variable: unknown) => {
223
223
  root = await mkdtemp(join(tmpdir(), 'toolsoauth-'));
224
- const tools = join(root, wsId, KB_DIR, 'Groups');
224
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
225
225
  await mkdir(tools, { recursive: true });
226
226
  await writeFile(
227
227
  join(tools, 'g.tool'),
@@ -268,7 +268,7 @@ describe('ToolManualService', () => {
268
268
 
269
269
  test('parses `remote`: default true, explicit false, rejects non-boolean', async () => {
270
270
  root = await mkdtemp(join(tmpdir(), 'toolsrem-'));
271
- const tools = join(root, wsId, KB_DIR, 'Groups');
271
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
272
272
  await mkdir(tools, { recursive: true });
273
273
  await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
274
274
  await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'mcp', url: 'https://x/m', remote: false }));
@@ -281,21 +281,21 @@ describe('ToolManualService', () => {
281
281
 
282
282
  test('remoteOnly excludes local-only manuals; listLocalOnly returns them', async () => {
283
283
  root = await mkdtemp(join(tmpdir(), 'toolsrem2-'));
284
- const tools = join(root, wsId, KB_DIR, 'Groups');
284
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
285
285
  await mkdir(tools, { recursive: true });
286
286
  await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
287
287
  await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'http', url: 'https://x/m', remote: false }));
288
288
  const s = svc();
289
289
  expect((await s.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name).sort()).toEqual(['pub']);
290
290
  expect((await s.toManualCallTemplates('user@x.eu')).map((t) => t.name).sort()).toEqual(['loc', 'pub']);
291
- expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Groups/loc.tool' }]);
291
+ expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Plugins/loc.tool' }]);
292
292
  // The local-only manual is still browsable/editable regardless of the remote flag.
293
293
  expect((await s.listAccessible('user@x.eu')).map((m) => m.name).sort()).toEqual(['loc', 'pub']);
294
294
  });
295
295
 
296
296
  test('refuses a `.tool` that reproduces a reserved built-in namespace', async () => {
297
297
  root = await mkdtemp(join(tmpdir(), 'toolsresv-'));
298
- const tools = join(root, wsId, KB_DIR, 'Groups');
298
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
299
299
  await mkdir(tools, { recursive: true });
300
300
  // A `.tool` named exactly like the internal manual, or with the KB namespace as
301
301
  // an explicit id, would resolve the loopback creds seeded under that namespace.
@@ -308,7 +308,7 @@ describe('ToolManualService', () => {
308
308
 
309
309
  test('refuses a remote `.tool` whose url is a private/loopback/metadata host (SSRF)', async () => {
310
310
  root = await mkdtemp(join(tmpdir(), 'toolsssrf-'));
311
- const tools = join(root, wsId, KB_DIR, 'Groups');
311
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
312
312
  await mkdir(tools, { recursive: true });
313
313
  await writeFile(join(tools, 'imds.tool'), JSON.stringify({ name: 'imds', type: 'http', url: 'http://169.254.169.254/latest/meta-data' }));
314
314
  await writeFile(join(tools, 'loop.tool'), JSON.stringify({ name: 'loop', type: 'mcp', url: 'http://127.0.0.1:9000/mcp' }));
@@ -322,7 +322,7 @@ describe('ToolManualService', () => {
322
322
 
323
323
  test('SSRF guard still applies when a private host is combined with a template token in the query', async () => {
324
324
  root = await mkdtemp(join(tmpdir(), 'toolsssrf3-'));
325
- const tools = join(root, wsId, KB_DIR, 'Groups');
325
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
326
326
  await mkdir(tools, { recursive: true });
327
327
  // A `${VAR}` in the QUERY doesn't make the authority templated — the host is
328
328
  // still the literal metadata endpoint, so the guard must reject it. Only a
@@ -335,7 +335,7 @@ describe('ToolManualService', () => {
335
335
 
336
336
  test('refuses any `.tool` that references a platform-seeded variable (API_URL / CONNECTION_KEY)', async () => {
337
337
  root = await mkdtemp(join(tmpdir(), 'toolsresvvar-'));
338
- const tools = join(root, wsId, KB_DIR, 'Groups');
338
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
339
339
  await mkdir(tools, { recursive: true });
340
340
  // Both reference forms (`${VAR}` and `$VAR`), in any `.tool` type and any
341
341
  // field — url query, header, an inline tool's call template. All refused at
@@ -358,7 +358,7 @@ describe('ToolManualService', () => {
358
358
 
359
359
  test('refuses a `.tool` that references a NAMESPACED reserved variable (`<ns>_API_URL` / `<ns>_CONNECTION_KEY`)', async () => {
360
360
  root = await mkdtemp(join(tmpdir(), 'toolsresvns-'));
361
- const tools = join(root, wsId, KB_DIR, 'Groups');
361
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
362
362
  await mkdir(tools, { recursive: true });
363
363
  // The substitutor resolves a manual's variables under its UTCP namespace, so
364
364
  // `${BEVEL_CONNECTION_KEY}` reaches the same seeded platform bearer the bare
@@ -386,7 +386,7 @@ describe('ToolManualService', () => {
386
386
 
387
387
  test('refuses a `.tool` that declares a variable named API_URL or CONNECTION_KEY', async () => {
388
388
  root = await mkdtemp(join(tmpdir(), 'toolsresvdecl-'));
389
- const tools = join(root, wsId, KB_DIR, 'Groups');
389
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
390
390
  await mkdir(tools, { recursive: true });
391
391
  await writeFile(
392
392
  join(tools, 'decl.tool'),
@@ -402,7 +402,7 @@ describe('ToolManualService', () => {
402
402
 
403
403
  test('SSRF guard validates the literal host even when userinfo or port are templated', async () => {
404
404
  root = await mkdtemp(join(tmpdir(), 'toolsssrf4-'));
405
- const tools = join(root, wsId, KB_DIR, 'Groups');
405
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
406
406
  await mkdir(tools, { recursive: true });
407
407
  // A `${VAR}` in the USERINFO or PORT doesn't make the hostname dynamic — the
408
408
  // fetch still targets the literal metadata IP, so the guard must reject it.
@@ -427,7 +427,7 @@ describe('ToolManualService', () => {
427
427
 
428
428
  test('SSRF guard exempts a local-only (remote:false) `.tool` and a templated url', async () => {
429
429
  root = await mkdtemp(join(tmpdir(), 'toolsssrf2-'));
430
- const tools = join(root, wsId, KB_DIR, 'Groups');
430
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
431
431
  await mkdir(tools, { recursive: true });
432
432
  // remote:false is never fetched server-side; a `${VAR}` url resolves at call time.
433
433
  await writeFile(join(tools, 'local.tool'), JSON.stringify({ name: 'localmcp', type: 'mcp', url: 'http://localhost:3333/mcp', remote: false }));
@@ -447,7 +447,7 @@ describe('ToolManualService', () => {
447
447
 
448
448
  test('parses `.tool` frontmatter: id is the manual name/namespace', async () => {
449
449
  root = await mkdtemp(join(tmpdir(), 'toolsfm-'));
450
- const tools = join(root, wsId, KB_DIR, 'Groups');
450
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
451
451
  await mkdir(tools, { recursive: true });
452
452
  // The tool IS the frontmatter: id + config in the one `---` block.
453
453
  await writeFile(
@@ -461,7 +461,7 @@ describe('ToolManualService', () => {
461
461
 
462
462
  test('snake_case id resolves scope via the UTCP doubled key', async () => {
463
463
  root = await mkdtemp(join(tmpdir(), 'toolssnake-'));
464
- const tools = join(root, wsId, KB_DIR, 'Groups');
464
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
465
465
  await mkdir(tools, { recursive: true });
466
466
  await writeFile(
467
467
  join(tools, 't.tool'),
@@ -475,7 +475,7 @@ describe('ToolManualService', () => {
475
475
 
476
476
  test('rejects a non-snake_case explicit id (file skipped)', async () => {
477
477
  root = await mkdtemp(join(tmpdir(), 'toolsbadid-'));
478
- const tools = join(root, wsId, KB_DIR, 'Groups');
478
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
479
479
  await mkdir(tools, { recursive: true });
480
480
  await writeFile(join(tools, 'x.tool'), '---\nid: My-Tool\ntype: http\nurl: https://x/m\n---\n');
481
481
  expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
@@ -483,7 +483,7 @@ describe('ToolManualService', () => {
483
483
 
484
484
  test('config after the closing fence is ignored (the tool is the frontmatter)', async () => {
485
485
  root = await mkdtemp(join(tmpdir(), 'toolsnotes-'));
486
- const tools = join(root, wsId, KB_DIR, 'Groups');
486
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
487
487
  await mkdir(tools, { recursive: true });
488
488
  // `type`/`url` live in the fence; the JSON after it is free-form notes.
489
489
  await writeFile(
@@ -497,7 +497,7 @@ describe('ToolManualService', () => {
497
497
 
498
498
  test('parses `description` onto the descriptor, trimmed', async () => {
499
499
  root = await mkdtemp(join(tmpdir(), 'toolsdesc-'));
500
- const tools = join(root, wsId, KB_DIR, 'Groups');
500
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
501
501
  await mkdir(tools, { recursive: true });
502
502
  await writeFile(
503
503
  join(tools, 'gh.tool'),
@@ -512,7 +512,7 @@ describe('ToolManualService', () => {
512
512
  // one must not: `description` buys a reader one sentence, and no sentence is
513
513
  // worth removing a working integration from the catalog.
514
514
  root = await mkdtemp(join(tmpdir(), 'toolsdescbad-'));
515
- const tools = join(root, wsId, KB_DIR, 'Groups');
515
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
516
516
  await mkdir(tools, { recursive: true });
517
517
  await writeFile(join(tools, 'num.tool'), JSON.stringify({ name: 'num', type: 'http', url: 'https://x/m', description: 42 }));
518
518
  await writeFile(join(tools, 'obj.tool'), JSON.stringify({ name: 'obj', type: 'http', url: 'https://x/m', description: { a: 1 } }));
@@ -31,7 +31,7 @@ const CATALOG = [
31
31
  {
32
32
  slug: 'weather',
33
33
  name: 'weather',
34
- path: 'Groups/weather.tool',
34
+ path: 'Plugins/weather.tool',
35
35
  type: 'mcp' as const,
36
36
  setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
37
37
  variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
@@ -39,7 +39,7 @@ const CATALOG = [
39
39
  {
40
40
  slug: 'billing',
41
41
  name: 'billing',
42
- path: 'Groups/billing.tool',
42
+ path: 'Plugins/billing.tool',
43
43
  type: 'http' as const,
44
44
  variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
45
45
  },
@@ -54,7 +54,7 @@ const toolManualService = {
54
54
 
55
55
  const accessControl = {
56
56
  canWrite: vi.fn(
57
- async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Groups/weather.tool',
57
+ async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Plugins/weather.tool',
58
58
  ),
59
59
  } as never;
60
60
 
@@ -0,0 +1,328 @@
1
+ import {
2
+ HEXIS_EXTENSION_NS,
3
+ PLUGIN_MCP_FILE,
4
+ PLUGIN_MANIFEST_FILE,
5
+ PLUGINS_DIR,
6
+ } from '@bevel-software/platform-shared';
7
+ import type { ToolManualDescriptor, ToolVariable } from './tool-manuals.contract.js';
8
+ import { assertSafeFetchUrl } from '../../shared/ssrf.js';
9
+ import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/variable-refs.js';
10
+
11
+ /**
12
+ * The same reserved-reference policy `.tool` parsing enforces, applied to the
13
+ * extension block: `API_URL`/`CONNECTION_KEY` (bare or namespaced) are seeded
14
+ * by the platform for its own manuals, and an extension header referencing one
15
+ * would be asking the substitutor to hand a third-party server the caller's
16
+ * bearer. One rule, both declaration surfaces — and one GRAMMAR deciding what
17
+ * a reference is (shared/variable-refs.ts), so nothing the migration or the
18
+ * editor classifies as a portable literal can be reserved here.
19
+ */
20
+ const referencesReservedVariable = findReservedVariableRef;
21
+
22
+ /**
23
+ * MCP servers are declared in each plugin's `mcp.json` (the Agent Plugins
24
+ * fixed location), not in `.tool` files — `mcp.json` is AUTHORITATIVE. This
25
+ * module turns one plugin's `mcp.json` (+ its `plugin.json` extensions block)
26
+ * into the same `ToolManualDescriptor`s the `.tool` scanner produces, so
27
+ * everything downstream — call templates, the vault's variable scoping, OAuth
28
+ * auto-discovery, `list_tool_setup` — is unchanged.
29
+ *
30
+ * The split between the two files is the specification's:
31
+ *
32
+ * - `mcp.json` holds what is PORTABLE: the server's name, transport, url,
33
+ * and literal headers. No credentials, no `${VAR}` references — a
34
+ * conformant client must transmit header values verbatim and expand
35
+ * nothing beyond `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`.
36
+ * - `plugin.json`'s `extensions["software.bevel.hexis"].mcpServers[<name>]`
37
+ * holds what is OURS: auth headers carrying `${VAR}` vault references,
38
+ * the `variables` declarations (scope/label/oauth), a `description`, and
39
+ * `local: true` for servers only reachable from a user's machine. The spec
40
+ * reserves `extensions` for exactly this, and other clients ignore it.
41
+ *
42
+ * The `mcpServers` KEY is the manual name — the namespace vault secrets bind
43
+ * to (`<name>_<VAR>`). The migration writes it from the old `.tool`'s id so
44
+ * configured secrets and completed OAuth grants stay bound; renaming a server
45
+ * key is renaming its secret namespace, and the editor should say so.
46
+ */
47
+
48
+ /** The extension block for one server, as we define it. */
49
+ export interface HexisMcpServerExtension {
50
+ /** Auth headers, values may carry `${VAR}` vault references. Merged OVER mcp.json's. */
51
+ headers?: Record<string, string>;
52
+ variables?: ToolVariable[];
53
+ description?: string;
54
+ /** Only reachable from a user's machine (e.g. localhost) — remote proxy skips it. */
55
+ local?: boolean;
56
+ }
57
+
58
+ function isRecord(v: unknown): v is Record<string, unknown> {
59
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
60
+ }
61
+
62
+ /** The `mcpServers` extension map from a parsed plugin.json, or `{}`. */
63
+ function extensionServers(pluginJson: unknown): Record<string, HexisMcpServerExtension> {
64
+ if (!isRecord(pluginJson)) return {};
65
+ const ext = pluginJson.extensions;
66
+ if (!isRecord(ext)) return {};
67
+ const ns = ext[HEXIS_EXTENSION_NS];
68
+ if (!isRecord(ns) || !isRecord(ns.mcpServers)) return {};
69
+ return ns.mcpServers as Record<string, HexisMcpServerExtension>;
70
+ }
71
+
72
+ /** A manual name must be usable as a UTCP namespace + route slug. Same shape `.tool` ids use. */
73
+ const SERVER_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
74
+
75
+ /**
76
+ * Validate an extension entry's `variables` declarations, or `null` when any
77
+ * entry is malformed. The `.tool` parser THROWS on a bad entry so the whole
78
+ * file is skipped — a variable silently dropped or mis-read is not cosmetic:
79
+ * an undeclared reference defaults to the shared `admin` scope, so losing a
80
+ * `scope: user` declaration would hand one caller's slot to everyone. The
81
+ * same stake applies here, at this file's per-server grain: a bad entry
82
+ * invalidates the SERVER, never its siblings.
83
+ *
84
+ * Exported for the mcp-server editor: what it saves is what this scan reads
85
+ * on the next pass, so the two must judge a declaration with ONE function —
86
+ * a shape saveable there but undiscoverable here would be a server that
87
+ * silently vanishes from the catalog the moment its edit lands.
88
+ */
89
+ export function validatedVariables(raw: unknown): ToolVariable[] | null {
90
+ if (raw === undefined) return [];
91
+ if (!Array.isArray(raw)) return null;
92
+ const out: ToolVariable[] = [];
93
+ const declared = new Set<string>();
94
+ for (const entry of raw) {
95
+ if (!isRecord(entry)) return null;
96
+ if (typeof entry.name !== 'string' || !/^[A-Za-z0-9_]+$/.test(entry.name)) return null;
97
+ // The `.tool` parser's declaration rules, at this file's grain: a
98
+ // platform-seeded name may not be re-declared (it would shadow the
99
+ // seeding), and a duplicate would make later scope resolution depend on
100
+ // declaration order.
101
+ if (RESERVED_VARIABLE_NAMES.includes(entry.name) || declared.has(entry.name)) return null;
102
+ declared.add(entry.name);
103
+ const scope = entry.scope ?? 'admin';
104
+ if (scope !== 'admin' && scope !== 'user') return null;
105
+ let oauth: ToolVariable['oauth'];
106
+ if (entry.oauth !== undefined) {
107
+ // OAuth is inherently per-caller (same rule the `.tool` parser
108
+ // enforces): an admin-shared OAuth token would leak one user's token
109
+ // to all callers. The provider config's required halves must be there
110
+ // — a sign-in wired to a missing URL is a declaration, not a feature.
111
+ if (!isRecord(entry.oauth) || scope !== 'user') return null;
112
+ const o = entry.oauth;
113
+ // `clientId` is trimmed and must be non-empty, exactly as the `.tool`
114
+ // parser requires: a whitespace-only value would pass discovery and
115
+ // then fail the owner's client-secret setup with "clientId is
116
+ // required" — an error at the wrong surface, long after the save.
117
+ if (
118
+ typeof o.authorizationUrl !== 'string' ||
119
+ typeof o.tokenUrl !== 'string' ||
120
+ typeof o.clientId !== 'string' ||
121
+ !o.clientId.trim()
122
+ ) {
123
+ return null;
124
+ }
125
+ // The same https + SSRF gate the `.tool` parser runs on these URLs: a
126
+ // sign-in or token exchange aimed at an internal host is a declaration
127
+ // this surface must refuse exactly like the other one does.
128
+ try {
129
+ assertSafeFetchUrl(o.authorizationUrl, { requireHttps: true, label: `${entry.name} oauth.authorizationUrl` });
130
+ assertSafeFetchUrl(o.tokenUrl, { requireHttps: true, label: `${entry.name} oauth.tokenUrl` });
131
+ } catch {
132
+ return null;
133
+ }
134
+ // Forwarded verbatim as query params later — a non-string value here is
135
+ // a malformed declaration, not something to coerce.
136
+ if (o.authParams !== undefined) {
137
+ if (!isRecord(o.authParams) || !Object.values(o.authParams).every((v) => typeof v === 'string')) {
138
+ return null;
139
+ }
140
+ }
141
+ oauth = {
142
+ authorizationUrl: o.authorizationUrl,
143
+ tokenUrl: o.tokenUrl,
144
+ clientId: o.clientId.trim(),
145
+ ...(Array.isArray(o.scopes) && o.scopes.every((s) => typeof s === 'string')
146
+ ? { scopes: o.scopes as string[] }
147
+ : {}),
148
+ ...(o.authParams !== undefined ? { authParams: o.authParams as Record<string, string> } : {}),
149
+ };
150
+ }
151
+ out.push({
152
+ name: entry.name,
153
+ scope,
154
+ ...(typeof entry.label === 'string' && entry.label.trim() ? { label: entry.label.trim() } : {}),
155
+ ...(oauth ? { oauth } : {}),
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Descriptors for one plugin's `mcp.json`. Malformed entries are skipped with
163
+ * a logged reason — one bad server must not take the plugin's others offline —
164
+ * and a missing/unparsable file yields `[]` (the caller decides whether that
165
+ * is worth a log line; an absent mcp.json is the common case, not an error).
166
+ *
167
+ * `stdio` entries are inherently LOCAL (`remote: false`): the hosted proxy can
168
+ * never spawn a subprocess out of knowledge-base content, so they are served
169
+ * only to local consumers (hexis-mcp), whose UTCP mcp plugin spawns them.
170
+ * `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders are NOT expanded yet — local
171
+ * materialization is a later phase — so a stdio server relying on them will
172
+ * fail to spawn until then; bare-command servers (`npx …`) work today.
173
+ */
174
+ export function descriptorsFromMcpJson(
175
+ pluginFolder: string,
176
+ mcpJsonText: string,
177
+ pluginJsonText: string | null,
178
+ ): ToolManualDescriptor[] {
179
+ let mcp: unknown;
180
+ try {
181
+ mcp = JSON.parse(mcpJsonText);
182
+ } catch {
183
+ console.warn(`[tool-manuals] ${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MCP_FILE} is not valid JSON — skipped.`);
184
+ return [];
185
+ }
186
+ if (!isRecord(mcp) || !isRecord(mcp.mcpServers)) return [];
187
+
188
+ let manifest: unknown = null;
189
+ if (pluginJsonText !== null) {
190
+ try {
191
+ manifest = JSON.parse(pluginJsonText);
192
+ } catch {
193
+ // A broken manifest costs the extension data (auth wiring), not the
194
+ // servers themselves — they are still listed, as the spec's own
195
+ // "invalid components are skipped, valid ones load" posture suggests.
196
+ console.warn(
197
+ `[tool-manuals] ${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MANIFEST_FILE} is not valid JSON — ` +
198
+ 'its mcp-server auth/variable declarations are ignored.',
199
+ );
200
+ }
201
+ }
202
+ const extensions = extensionServers(manifest);
203
+ const mcpJsonPath = `${PLUGINS_DIR}/${pluginFolder}/${PLUGIN_MCP_FILE}`;
204
+
205
+ const out: ToolManualDescriptor[] = [];
206
+ for (const [name, raw] of Object.entries(mcp.mcpServers)) {
207
+ if (!SERVER_NAME_RE.test(name)) {
208
+ console.warn(
209
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: the name is the secret ` +
210
+ 'namespace and route slug, so it must be lowercase alphanumeric with `_`/`-`.',
211
+ );
212
+ continue;
213
+ }
214
+ if (!isRecord(raw) || typeof raw.type !== 'string') {
215
+ console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no transport type.`);
216
+ continue;
217
+ }
218
+ // The extension entry is knowledge-base content too — same zero-trust
219
+ // parse as the rest: a non-object entry reads as "no extension data".
220
+ const ext: HexisMcpServerExtension = isRecord(extensions[name])
221
+ ? (extensions[name] as HexisMcpServerExtension)
222
+ : {};
223
+ // The EFFECTIVE declaration, not just the extension block: a reserved
224
+ // reference in mcp.json's own url or literal headers would be expanded
225
+ // into outbound requests exactly the same way.
226
+ const reserved = referencesReservedVariable({ raw, ext });
227
+ if (reserved !== null) {
228
+ console.warn(
229
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: its declaration (mcp.json ` +
230
+ `entry or plugin.json extension) references the reserved variable "${reserved}" — API_URL and ` +
231
+ 'CONNECTION_KEY are platform-seeded and may not appear in server declarations.',
232
+ );
233
+ continue;
234
+ }
235
+ const variables = validatedVariables(ext.variables);
236
+ if (variables === null) {
237
+ console.warn(
238
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: its plugin.json \`variables\` ` +
239
+ 'declaration is malformed — a dropped declaration would silently re-scope a credential, so ' +
240
+ 'the server stays offline until the manifest is fixed.',
241
+ );
242
+ continue;
243
+ }
244
+ const shared = {
245
+ slug: name,
246
+ name,
247
+ path: mcpJsonPath,
248
+ type: 'mcp' as const,
249
+ ...(typeof ext.description === 'string' ? { description: ext.description } : {}),
250
+ ...(variables.length > 0 ? { variables } : {}),
251
+ };
252
+
253
+ if (raw.type === 'stdio') {
254
+ if (typeof raw.command !== 'string' || raw.command.length === 0) {
255
+ console.warn(`[tool-manuals] skipping stdio server "${name}" in ${mcpJsonPath}: no command.`);
256
+ continue;
257
+ }
258
+ out.push({
259
+ ...shared,
260
+ remote: false,
261
+ stdio: {
262
+ command: raw.command,
263
+ args: Array.isArray(raw.args) ? raw.args.map(String) : [],
264
+ env: isRecord(raw.env) ? (raw.env as Record<string, string>) : undefined,
265
+ cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
266
+ },
267
+ });
268
+ continue;
269
+ }
270
+
271
+ if (raw.type === 'streamable-http' || raw.type === 'sse') {
272
+ if (typeof raw.url !== 'string' || raw.url.length === 0) {
273
+ console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no url.`);
274
+ continue;
275
+ }
276
+ // Remote-capable servers get the same SSRF gate `.tool` urls pass —
277
+ // otherwise mcp.json becomes the way to point the backend at loopback,
278
+ // private ranges, or the cloud metadata endpoint. A `local: true` entry
279
+ // is exempt because loopback is exactly what local MEANS, and only the
280
+ // user's own machine ever dials it.
281
+ // Scheme first, for EVERYONE: `local: true` exempts a server from the
282
+ // private-network reachability policy, not from being http(s) at all.
283
+ let schemeOk = false;
284
+ try {
285
+ const u = new URL(raw.url);
286
+ schemeOk = u.protocol === 'http:' || u.protocol === 'https:';
287
+ } catch {
288
+ schemeOk = false;
289
+ }
290
+ if (!schemeOk) {
291
+ console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: url must be http(s).`);
292
+ continue;
293
+ }
294
+ if (ext.local !== true) {
295
+ try {
296
+ assertSafeFetchUrl(raw.url, { label: `mcp server "${name}" url` });
297
+ } catch (err) {
298
+ console.warn(
299
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: ` +
300
+ `${err instanceof Error ? err.message : String(err)} (declare it \`local: true\` if it is deliberately private).`,
301
+ );
302
+ continue;
303
+ }
304
+ }
305
+ // Extension headers (auth, `${VAR}` refs) win over mcp.json's literal
306
+ // ones on a key collision: the portable file cannot carry a credential,
307
+ // so when both name the same header the extension is the operative one.
308
+ // Both sides pass the isRecord gate — spreading a malformed non-object
309
+ // value (a string, say) would scatter its indices into header keys.
310
+ const headers = {
311
+ ...(isRecord(raw.headers) ? (raw.headers as Record<string, string>) : {}),
312
+ ...(isRecord(ext.headers) ? (ext.headers as Record<string, string>) : {}),
313
+ };
314
+ out.push({
315
+ ...shared,
316
+ url: raw.url,
317
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
318
+ ...(ext.local === true ? { remote: false } : {}),
319
+ });
320
+ continue;
321
+ }
322
+
323
+ // Unknown transport: the spec says an unknown `type` invalidates the
324
+ // ENTRY, not the file — skip it, keep its siblings.
325
+ console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: unknown type "${raw.type}".`);
326
+ }
327
+ return out;
328
+ }